You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by la...@apache.org on 2026/03/24 15:56:39 UTC
(airavata-custos) 01/01: initial signer service implementation
This is an automated email from the ASF dual-hosted git repository.
lahirujayathilake pushed a commit to branch signer-service
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
commit 5cb1298aed1c2dfaa26f111e0d5a41ed079b2aeb
Author: lahiruj <la...@gmail.com>
AuthorDate: Tue Mar 24 11:56:28 2026 -0400
initial signer service implementation
---
compose/dbinit/init-db.sh | 2 +
signer/.gitignore | 5 +
signer/README.md | 276 ++++++++++++++
signer/config.example.yaml | 66 ++++
signer/go.mod | 55 +++
signer/go.sum | 192 ++++++++++
signer/internal/audit/logger.go | 145 +++++++
signer/internal/auth/client_auth.go | 99 +++++
signer/internal/auth/client_auth_test.go | 81 ++++
signer/internal/auth/oidc_validator.go | 296 +++++++++++++++
signer/internal/auth/oidc_validator_test.go | 457 +++++++++++++++++++++++
signer/internal/cert/rsa_ecdsa_signing_test.go | 210 +++++++++++
signer/internal/cert/signer.go | 110 ++++++
signer/internal/cert/signer_test.go | 246 ++++++++++++
signer/internal/cert/ssh_key.go | 237 ++++++++++++
signer/internal/cert/ssh_key_test.go | 306 +++++++++++++++
signer/internal/config/config.go | 262 +++++++++++++
signer/internal/config/config_test.go | 122 ++++++
signer/internal/handler/admin.go | 88 +++++
signer/internal/handler/ca_public_key.go | 93 +++++
signer/internal/handler/ca_public_key_test.go | 78 ++++
signer/internal/handler/certificates.go | 179 +++++++++
signer/internal/handler/certificates_test.go | 238 ++++++++++++
signer/internal/handler/common.go | 26 ++
signer/internal/handler/health.go | 119 ++++++
signer/internal/handler/jwks.go | 102 +++++
signer/internal/handler/revoke.go | 110 ++++++
signer/internal/handler/revoke_test.go | 53 +++
signer/internal/handler/sign.go | 298 +++++++++++++++
signer/internal/handler/sign_test.go | 85 +++++
signer/internal/handler/userinfo.go | 55 +++
signer/internal/handler/userinfo_test.go | 93 +++++
signer/internal/httputil/context.go | 95 +++++
signer/internal/httputil/context_test.go | 92 +++++
signer/internal/metrics/metrics.go | 56 +++
signer/internal/metrics/metrics_test.go | 62 +++
signer/internal/policy/enforcer.go | 97 +++++
signer/internal/policy/enforcer_test.go | 167 +++++++++
signer/internal/policy/key_type.go | 17 +
signer/internal/server/bearer_middleware_test.go | 125 +++++++
signer/internal/server/cors.go | 50 +++
signer/internal/server/cors_test.go | 125 +++++++
signer/internal/server/middleware.go | 150 ++++++++
signer/internal/server/middleware_test.go | 124 ++++++
signer/internal/server/routes.go | 84 +++++
signer/internal/server/server.go | 82 ++++
signer/internal/store/certificate_query.go | 148 ++++++++
signer/internal/store/client_config.go | 88 +++++
signer/internal/store/db.go | 57 +++
signer/internal/store/issuance_log.go | 65 ++++
signer/internal/store/revocation.go | 58 +++
signer/internal/validation/comanage.go | 30 ++
signer/internal/validation/noop.go | 30 ++
signer/internal/validation/validator.go | 38 ++
signer/internal/validation/validator_test.go | 65 ++++
signer/internal/vault/client.go | 390 +++++++++++++++++++
signer/main.go | 218 +++++++++++
signer/migrations/001_initial_schema.up.sql | 79 ++++
58 files changed, 7376 insertions(+)
diff --git a/compose/dbinit/init-db.sh b/compose/dbinit/init-db.sh
index c253a1bd9..e94a48596 100755
--- a/compose/dbinit/init-db.sh
+++ b/compose/dbinit/init-db.sh
@@ -23,10 +23,12 @@ mariadb -u root -p"${MARIADB_ROOT_PASSWORD}" <<-EOSQL
CREATE DATABASE IF NOT EXISTS custos;
CREATE DATABASE IF NOT EXISTS keycloak;
CREATE DATABASE IF NOT EXISTS access_ci;
+ CREATE DATABASE IF NOT EXISTS custos_signer;
CREATE USER IF NOT EXISTS 'admin'@'%' IDENTIFIED BY 'admin';
GRANT ALL PRIVILEGES ON custos.* TO 'admin'@'%';
GRANT ALL PRIVILEGES ON keycloak.* TO 'admin'@'%';
GRANT ALL PRIVILEGES ON access_ci.* TO 'admin'@'%';
+ GRANT ALL PRIVILEGES ON custos_signer.* TO 'admin'@'%';
FLUSH PRIVILEGES;
EOSQL
diff --git a/signer/.gitignore b/signer/.gitignore
new file mode 100644
index 000000000..3221db7ab
--- /dev/null
+++ b/signer/.gitignore
@@ -0,0 +1,5 @@
+# Local configuration (contains secrets)
+config.yaml
+
+# Build output
+custos-signer
diff --git a/signer/README.md b/signer/README.md
new file mode 100644
index 000000000..4b7761ef3
--- /dev/null
+++ b/signer/README.md
@@ -0,0 +1,276 @@
+# Custos Signer
+
+A multi-tenant SSH certificate authority for science gateways and research computing. Custos Signer issues short-lived, identity-bound OpenSSH user certificates so researchers can access HPC resources without managing long-lived SSH keys.
+
+It validates user identity through institutional OIDC providers (CILogon, Keycloak, etc.), enforces access policies (TTL limits, allowed key types, principal validation), signs certificates with per-tenant CAs stored in HashiCorp Vault, and records every transaction for audit. Certificates expire automatically -- no key distribution, no manual revocation, no cleanup.
+
+Part of [Apache Airavata Custos](https://airavata.apache.org/custos/), a security middleware platform for science gateways.
+
+## Prerequisites
+
+- Go 1.22+
+- MariaDB 10.6+
+- Vault / OpenBao with KV v2 secrets engine enabled
+
+---
+
+## Quick Start
+
+```bash
+cd signer
+go build -o custos-signer .
+cp config.example.yaml config.yaml # edit with your settings
+./custos-signer migrate --config config.yaml # apply database schema
+./custos-signer serve --config config.yaml # start on :8084
+```
+
+For development (auto-applies migrations on startup):
+
+```bash
+./custos-signer serve --config config.yaml --auto-migrate
+```
+
+---
+
+## Configuration
+
+Copy and edit the example configuration:
+
+```bash
+cp config.example.yaml config.yaml
+```
+
+The service loads `config.yaml` by default. Override with `--config /path/to/config.yaml`.
+
+### Environment Variable Overrides
+
+Environment variables take precedence over YAML values.
+
+| Env Var | Config Path | Description |
+|---------|------------|-------------|
+| `DB_HOST` | `database.host` | Database hostname |
+| `DB_PORT` | `database.port` | Database port |
+| `DB_NAME` | `database.name` | Database name |
+| `DB_USERNAME` | `database.username` | Database user |
+| `DB_PASSWORD` | `database.password` | Database password |
+| `VAULT_ADDRESS` | `vault.address` | Vault API address |
+| `VAULT_TOKEN` | `vault.token` | Vault authentication token |
+| `DEV_MODE` | `dev_mode.enabled` | Enable dev mode (disables OIDC validation) |
+| `DEV_DEFAULT_EMAIL` | `dev_mode.default_email` | Default email in dev mode |
+| `ALLOWED_ISSUERS` | `signer.auth.allowed_issuers` | Comma-separated list of allowed OIDC issuers |
+| `LOG_LEVEL` | `logging.level` | Log level: debug, info, warn, error |
+
+---
+
+## Database Setup
+
+### Create the database
+
+```sql
+CREATE DATABASE custos_signer CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+```
+
+If using the project's Docker Compose, the `custos_signer` database is created automatically by `compose/dbinit/init-db.sh`.
+
+### Migrations
+
+Migrations are managed separately from the server. Apply them explicitly:
+
+```bash
+./custos-signer migrate --config config.yaml
+```
+
+This applies any pending migrations and exits. It's idempotent — safe to run repeatedly. The `migrate` subcommand tracks applied versions in a `schema_migrations` table and only applies what's new.
+
+You can also apply SQL directly:
+
+```bash
+mysql -u admin -p custos_signer < migrations/001_initial_schema.up.sql
+```
+
+The initial migration creates three tables:
+- `client_ssh_configs` — client configuration and credentials
+- `certificate_issuance_logs` — audit log for certificate issuances
+- `revocation_events` — audit log for certificate revocations
+
+### Insert a client configuration
+
+```sql
+INSERT INTO client_ssh_configs
+ (tenant_id, client_id, client_secret, target_host, target_port,
+ max_ttl_seconds, allowed_key_types, enabled)
+VALUES
+ ('tenant1', 'webapp', '$2a$10$<bcrypt_hash_of_secret>', 'login.example.org', 22,
+ 86400, '["ed25519","rsa","ecdsa"]', TRUE);
+```
+
+Generate the bcrypt hash with:
+
+```bash
+htpasswd -nbBC 10 "" "your-secret" | cut -d: -f2
+```
+
+---
+
+## Vault Setup
+
+### Enable KV v2 secrets engine
+
+```bash
+vault secrets enable -path=ssh-ca kv-v2
+```
+
+The service auto-generates Ed25519 CA key pairs on first signing request for each tenant-client pair. No manual key setup is required.
+
+Ensure the configured token has read/write access to `ssh-ca/data/*`.
+
+---
+
+## Running the Service
+
+### CLI Usage
+
+```
+custos-signer <command> [options]
+
+Commands:
+ serve Start the HTTP server (default)
+ migrate Apply pending database migrations and exit
+
+Options:
+ --config string Path to configuration file (default "config.yaml")
+ --auto-migrate Automatically apply migrations on startup (serve only)
+```
+
+Running without a subcommand defaults to `serve`.
+
+### Development
+
+```bash
+./custos-signer serve --config config.yaml --auto-migrate
+```
+
+### Production
+
+```bash
+./custos-signer migrate --config config.yaml # apply pending migrations
+./custos-signer serve --config config.yaml # start the server
+```
+
+The service listens on the port specified in configuration (default: 8084).
+
+### Docker
+
+```bash
+docker build -t custos-signer .
+
+# Apply migrations first
+docker run --rm -v $(pwd)/config.yaml:/config/config.yaml \
+ custos-signer migrate --config /config/config.yaml
+
+# Then start the server
+docker run -p 8084:8084 \
+ -v $(pwd)/config.yaml:/config/config.yaml \
+ -e DB_PASSWORD=secret \
+ -e VAULT_TOKEN=hvs.xxx \
+ custos-signer
+```
+
+The Docker image uses a `scratch` base and runs as non-root user (UID 65534).
+
+### Shutdown Behavior
+
+The service handles SIGTERM and SIGINT for graceful shutdown:
+1. Stops accepting new connections.
+2. Drains in-flight requests (up to `shutdown_timeout_seconds`, default 30s).
+3. Closes database connection pool.
+4. Exits.
+
+---
+
+## API Endpoints
+
+| Method | Path | Auth | Description |
+|--------|------|------|-------------|
+| `POST` | `/api/v1/sign` | Client credentials + OIDC token | Sign an SSH public key |
+| `POST` | `/api/v1/revoke` | Client credentials | Revoke a certificate |
+| `GET` | `/api/v1/jwks` | Client credentials | Get CA public keys in JWK format |
+| `GET` | `/api/v1/health` | None | Health check |
+| `POST` | `/api/v1/admin/rotate-ca` | Client credentials | Rotate CA keys |
+| `GET` | `/api/v1/certificates` | OIDC Bearer | List certificates for authenticated user |
+| `GET` | `/api/v1/certificates/{serial}` | OIDC Bearer | Get certificate details |
+| `GET` | `/api/v1/userinfo` | OIDC Bearer | Get authenticated user profile |
+| `GET` | `/metrics` | None | Prometheus metrics |
+
+Client credentials are passed via `X-Client-Id` (format: `{tenant_id}:{client_id}`) and `X-Client-Secret` headers. OIDC Bearer endpoints use `Authorization: Bearer <token>`.
+
+### API Examples
+
+**Health Check**
+
+```bash
+curl -s http://localhost:8084/api/v1/health | jq .
+```
+
+**Sign a Certificate**
+
+```bash
+ssh-keygen -t ed25519 -f /tmp/testkey -N ""
+
+curl -s -X POST http://localhost:8084/api/v1/sign \
+ -H "Content-Type: application/json" \
+ -H "X-Client-Id: tenant1:webapp" \
+ -H "X-Client-Secret: your-secret" \
+ -d '{
+ "principal": "jdoe",
+ "ttl_seconds": 3600,
+ "public_key": "'"$(cat /tmp/testkey.pub)"'",
+ "user_access_token": "<oidc-access-token>"
+ }' | jq .
+```
+
+**Revoke a Certificate**
+
+```bash
+curl -s -X POST http://localhost:8084/api/v1/revoke \
+ -H "Content-Type: application/json" \
+ -H "X-Client-Id: tenant1:webapp" \
+ -H "X-Client-Secret: your-secret" \
+ -d '{"serial_number": 42, "reason": "User deprovisioned"}' | jq .
+```
+
+**Get CA Public Keys (JWK format)**
+
+```bash
+curl -s http://localhost:8084/api/v1/jwks \
+ -H "X-Client-Id: tenant1:webapp" \
+ -H "X-Client-Secret: your-secret" | jq .
+```
+
+**Rotate CA Keys**
+
+```bash
+curl -s -X POST http://localhost:8084/api/v1/admin/rotate-ca \
+ -H "X-Client-Id: tenant1:webapp" \
+ -H "X-Client-Secret: your-secret" | jq .
+```
+
+---
+
+## Troubleshooting
+
+| Error | Cause | Fix |
+|-------|-------|-----|
+| "failed to connect to database" | MariaDB unreachable or wrong credentials | Check host/port, verify DB user permissions, ensure database exists |
+| "failed to create vault client" | Vault unreachable or bad token | Check `VAULT_ADDRESS`, ensure token is valid, verify KV v2 enabled |
+| HTTP 401 on authenticated requests | Bad client credentials | Verify `X-Client-Id` is `tenant_id:client_id` format, check bcrypt hash matches |
+| HTTP 403 "Client is disabled" | Client row has `enabled=false` | Set `enabled=true` in `client_ssh_configs` |
+| HTTP 401 "Issuer not allowed" | OIDC issuer not in allowed list | Add issuer to `allowed_issuers`, or set to empty list to accept all |
+| HTTP 503 "CA key storage unavailable" | Vault down during signing | Check Vault status and network connectivity |
+| Token validation errors in dev | No OIDC provider configured | Set `DEV_MODE=true` for development (returns default identity) |
+
+---
+
+## License
+
+Apache License 2.0
diff --git a/signer/config.example.yaml b/signer/config.example.yaml
new file mode 100644
index 000000000..1552f099c
--- /dev/null
+++ b/signer/config.example.yaml
@@ -0,0 +1,66 @@
+server:
+ port: 8084 # HTTP listen port
+ read_timeout_seconds: 30 # HTTP read timeout
+ write_timeout_seconds: 30 # HTTP write timeout
+ shutdown_timeout_seconds: 30 # Graceful shutdown drain
+
+database:
+ host: localhost # env: DB_HOST
+ port: 3306 # env: DB_PORT
+ name: custos_signer # env: DB_NAME
+ username: admin # env: DB_USERNAME
+ password: admin # env: DB_PASSWORD
+ max_open_conns: 25
+ max_idle_conns: 5
+ conn_max_lifetime_seconds: 300
+
+vault:
+ address: http://localhost:8200 # env: VAULT_ADDRESS
+ token: root-token # env: VAULT_TOKEN
+ mount_path: ssh-ca # KV v2 mount path
+ timeout_seconds: 10
+
+signer:
+ ca:
+ rotation:
+ period_hours: 2160 # 90 days
+ overlap_hours: 2
+ policy:
+ defaults:
+ max_ttl_seconds: 86400 # 24 hours
+ allowed_key_types:
+ - ed25519
+ - rsa
+ - ecdsa
+ auth:
+ allowed_issuers: # env: ALLOWED_ISSUERS (comma-separated)
+ - https://cilogon.org
+ oidc:
+ jwks_cache_ttl_seconds: 300 # 5 minutes
+ jwks_max_providers: 10
+ timeout_seconds: 10
+ validation:
+ principal_validator: noop # "noop" or "comanage"
+ comanage:
+ registry_url: ""
+ api_path: /registry/co_people.json
+ timeout_seconds: 10
+ verify_ssl: true
+
+dev_mode:
+ enabled: false # env: DEV_MODE (disables OIDC validation)
+ default_email: dev@localhost # env: DEV_DEFAULT_EMAIL
+
+cors:
+ enabled: false # Enable CORS for cross-origin requests
+ allowed_origins: # Origins allowed for cross-origin requests
+ - http://localhost:5173
+
+logging:
+ level: info # debug, info, warn, error
+ format: json # json or text
+ audit_level: info
+
+metrics:
+ enabled: true
+ path: /metrics
diff --git a/signer/go.mod b/signer/go.mod
new file mode 100644
index 000000000..4165ac052
--- /dev/null
+++ b/signer/go.mod
@@ -0,0 +1,55 @@
+module github.com/apache/airavata-custos/signer
+
+go 1.25.0
+
+require (
+ github.com/go-chi/chi/v5 v5.1.0
+ github.com/go-sql-driver/mysql v1.8.1
+ github.com/golang-migrate/migrate/v4 v4.19.1
+ github.com/hashicorp/vault/api v1.15.0
+ github.com/lestrrat-go/jwx/v2 v2.1.3
+ github.com/prometheus/client_golang v1.20.5
+ golang.org/x/crypto v0.45.0
+ gopkg.in/yaml.v3 v3.0.1
+)
+
+require (
+ filippo.io/edwards25519 v1.1.0 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
+ github.com/go-jose/go-jose/v4 v4.0.5 // indirect
+ github.com/goccy/go-json v0.10.3 // indirect
+ github.com/hashicorp/errwrap v1.1.0 // indirect
+ github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+ github.com/hashicorp/go-multierror v1.1.1 // indirect
+ github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
+ github.com/hashicorp/go-rootcerts v1.0.2 // indirect
+ github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect
+ github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
+ github.com/hashicorp/go-sockaddr v1.0.2 // indirect
+ github.com/hashicorp/hcl v1.0.0 // indirect
+ github.com/klauspost/compress v1.17.9 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/lestrrat-go/blackmagic v1.0.2 // indirect
+ github.com/lestrrat-go/httpcc v1.0.1 // indirect
+ github.com/lestrrat-go/httprc v1.0.6 // indirect
+ github.com/lestrrat-go/iter v1.0.2 // indirect
+ github.com/lestrrat-go/option v1.0.1 // indirect
+ github.com/mitchellh/go-homedir v1.1.0 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/prometheus/client_model v0.6.1 // indirect
+ github.com/prometheus/common v0.55.0 // indirect
+ github.com/prometheus/procfs v0.15.1 // indirect
+ github.com/rogpeppe/go-internal v1.12.0 // indirect
+ github.com/ryanuber/go-glob v1.0.0 // indirect
+ github.com/segmentio/asm v1.2.0 // indirect
+ golang.org/x/net v0.47.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.38.0 // indirect
+ golang.org/x/text v0.31.0 // indirect
+ golang.org/x/time v0.12.0 // indirect
+ google.golang.org/protobuf v1.36.7 // indirect
+)
diff --git a/signer/go.sum b/signer/go.sum
new file mode 100644
index 000000000..d12cd5b9b
--- /dev/null
+++ b/signer/go.sum
@@ -0,0 +1,192 @@
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
+github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
+github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
+github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
+github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
+github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
+github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
+github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
+github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
+github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
+github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
+github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
+github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
+github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
+github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
+github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
+github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
+github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
+github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
+github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
+github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw=
+github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
+github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
+github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
+github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
+github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
+github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
+github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
+github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
+github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
+github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
+github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
+github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 h1:om4Al8Oy7kCm/B86rLCLah4Dt5Aa0Fr5rYBG60OzwHQ=
+github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8=
+github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U=
+github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
+github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
+github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc=
+github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A=
+github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA=
+github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8=
+github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
+github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
+github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
+github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
+github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
+github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
+github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
+github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
+github.com/lestrrat-go/jwx/v2 v2.1.3 h1:Ud4lb2QuxRClYAmRleF50KrbKIoM1TddXgBrneT5/Jo=
+github.com/lestrrat-go/jwx/v2 v2.1.3/go.mod h1:q6uFgbgZfEmQrfJfrCo90QcQOcXFMfbI/fO0NqRtvZo=
+github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
+github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
+github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
+github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
+github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
+github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
+github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
+github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
+github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
+github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
+github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
+github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
+github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
+github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
+github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
+github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
+github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
+github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
+github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
+github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
+github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
+github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
+go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
+go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
+go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
+go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
+go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
+go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
+go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
+golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
+golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
+golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
+golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
+google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
+google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/signer/internal/audit/logger.go b/signer/internal/audit/logger.go
new file mode 100644
index 000000000..b73529f3d
--- /dev/null
+++ b/signer/internal/audit/logger.go
@@ -0,0 +1,145 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package audit provides dual audit logging (database + structured JSON log).
+package audit
+
+import (
+ "context"
+ "log/slog"
+ "time"
+
+ "github.com/apache/airavata-custos/signer/internal/store"
+)
+
+// Logger writes audit entries to both the database and structured slog output.
+type Logger struct {
+ db *store.DB
+ slogger *slog.Logger
+}
+
+func NewLogger(db *store.DB, baseLogger *slog.Logger) *Logger {
+ return &Logger{
+ db: db,
+ slogger: baseLogger.With("logger", "audit"),
+ }
+}
+
+type IssuanceEntry struct {
+ TenantID string
+ ClientID string
+ SerialNumber int64
+ KeyID string
+ Principal string
+ UserEmail string
+ PublicKeyFingerprint string
+ CAFingerprint string
+ ValidAfter time.Time
+ ValidBefore time.Time
+ SourceIP string
+ UserAccessTokenHash string
+ CorrelationID string
+}
+
+func (l *Logger) LogIssuance(ctx context.Context, entry *IssuanceEntry) error {
+ dbLog := &store.IssuanceLog{
+ TenantID: entry.TenantID,
+ ClientID: entry.ClientID,
+ SerialNumber: entry.SerialNumber,
+ KeyID: entry.KeyID,
+ Principal: entry.Principal,
+ UserEmail: entry.UserEmail,
+ PublicKeyFingerprint: entry.PublicKeyFingerprint,
+ CAFingerprint: entry.CAFingerprint,
+ ValidAfter: entry.ValidAfter,
+ ValidBefore: entry.ValidBefore,
+ SourceIP: entry.SourceIP,
+ UserAccessTokenHash: entry.UserAccessTokenHash,
+ }
+
+ if err := l.db.InsertIssuanceLog(ctx, dbLog); err != nil {
+ return err
+ }
+
+ l.slogger.InfoContext(ctx, "certificate issued",
+ "event_type", "certificate_issuance",
+ "timestamp", time.Now().UTC().Format(time.RFC3339),
+ "tenant_id", entry.TenantID,
+ "client_id", entry.ClientID,
+ "serial_number", entry.SerialNumber,
+ "key_id", entry.KeyID,
+ "principal", entry.Principal,
+ "user_email", entry.UserEmail,
+ "public_key_fingerprint", entry.PublicKeyFingerprint,
+ "ca_fingerprint", entry.CAFingerprint,
+ "valid_after", entry.ValidAfter.Unix(),
+ "valid_before", entry.ValidBefore.Unix(),
+ "source_ip", entry.SourceIP,
+ "user_token_hash", entry.UserAccessTokenHash,
+ "correlation_id", entry.CorrelationID,
+ )
+
+ return nil
+}
+
+type RevocationEntry struct {
+ TenantID string
+ ClientID string
+ SerialNumber *int64
+ KeyID *string
+ CAFingerprint *string
+ Reason string
+ RevokedBy string
+ CorrelationID string
+}
+
+func (l *Logger) LogRevocation(ctx context.Context, entry *RevocationEntry) error {
+ ev := &store.RevocationEvent{
+ TenantID: entry.TenantID,
+ ClientID: entry.ClientID,
+ SerialNumber: entry.SerialNumber,
+ KeyID: entry.KeyID,
+ CAFingerprint: entry.CAFingerprint,
+ Reason: entry.Reason,
+ RevokedBy: entry.RevokedBy,
+ }
+
+ if err := l.db.InsertRevocationEvent(ctx, ev); err != nil {
+ return err
+ }
+
+ attrs := []any{
+ "event_type", "certificate_revocation",
+ "timestamp", time.Now().UTC().Format(time.RFC3339),
+ "tenant_id", entry.TenantID,
+ "client_id", entry.ClientID,
+ "reason", entry.Reason,
+ "revoked_by", entry.RevokedBy,
+ "correlation_id", entry.CorrelationID,
+ }
+ if entry.SerialNumber != nil {
+ attrs = append(attrs, "serial_number", *entry.SerialNumber)
+ }
+ if entry.KeyID != nil {
+ attrs = append(attrs, "key_id", *entry.KeyID)
+ }
+ if entry.CAFingerprint != nil {
+ attrs = append(attrs, "ca_fingerprint", *entry.CAFingerprint)
+ }
+
+ l.slogger.InfoContext(ctx, "certificate revoked", attrs...)
+
+ return nil
+}
diff --git a/signer/internal/auth/client_auth.go b/signer/internal/auth/client_auth.go
new file mode 100644
index 000000000..550306d9d
--- /dev/null
+++ b/signer/internal/auth/client_auth.go
@@ -0,0 +1,99 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package auth handles client credential authentication and OIDC token validation.
+package auth
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "golang.org/x/crypto/bcrypt"
+
+ "github.com/apache/airavata-custos/signer/internal/store"
+)
+
+type ClientAuthenticator struct {
+ db *store.DB
+}
+
+func NewClientAuthenticator(db *store.DB) *ClientAuthenticator {
+ return &ClientAuthenticator{db: db}
+}
+
+// ParseClientID parses the X-Client-Id header value into tenant_id and client_id.
+// The header format is {tenant_id}:{client_id}, split on the first colon only.
+func ParseClientID(header string) (tenantID, clientID string, err error) {
+ if header == "" {
+ return "", "", fmt.Errorf("empty client ID header")
+ }
+ idx := strings.Index(header, ":")
+ if idx < 0 {
+ return "", "", fmt.Errorf("invalid client ID format: no colon separator")
+ }
+ tenantID = header[:idx]
+ clientID = header[idx+1:]
+ if tenantID == "" || clientID == "" {
+ return "", "", fmt.Errorf("invalid client ID format: empty tenant or client")
+ }
+ return tenantID, clientID, nil
+}
+
+type AuthResult struct {
+ Config *store.ClientConfig
+}
+
+func (a *ClientAuthenticator) Authenticate(ctx context.Context, clientIDHeader, secretHeader string) (*AuthResult, error) {
+ if clientIDHeader == "" {
+ return nil, &AuthError{Code: "unauthorized", Message: "Missing X-Client-Id header", Status: 401}
+ }
+ if secretHeader == "" {
+ return nil, &AuthError{Code: "unauthorized", Message: "Missing X-Client-Secret header", Status: 401}
+ }
+
+ tenantID, clientID, err := ParseClientID(clientIDHeader)
+ if err != nil {
+ return nil, &AuthError{Code: "unauthorized", Message: err.Error(), Status: 401}
+ }
+
+ cfg, err := a.db.GetClientConfig(ctx, tenantID, clientID)
+ if err != nil {
+ return nil, fmt.Errorf("looking up client config: %w", err)
+ }
+ if cfg == nil {
+ return nil, &AuthError{Code: "unauthorized", Message: "Unknown client", Status: 401}
+ }
+
+ if err := bcrypt.CompareHashAndPassword([]byte(cfg.ClientSecret), []byte(secretHeader)); err != nil {
+ return nil, &AuthError{Code: "unauthorized", Message: "Invalid client secret", Status: 401}
+ }
+
+ if !cfg.Enabled {
+ return nil, &AuthError{Code: "client_disabled", Message: "Client is disabled", Status: 403}
+ }
+
+ return &AuthResult{Config: cfg}, nil
+}
+
+type AuthError struct {
+ Code string
+ Message string
+ Status int
+}
+
+func (e *AuthError) Error() string {
+ return e.Message
+}
diff --git a/signer/internal/auth/client_auth_test.go b/signer/internal/auth/client_auth_test.go
new file mode 100644
index 000000000..2bd4bf93b
--- /dev/null
+++ b/signer/internal/auth/client_auth_test.go
@@ -0,0 +1,81 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package auth
+
+import (
+ "testing"
+)
+
+func TestParseClientID_NoColon(t *testing.T) {
+ _, _, err := ParseClientID("nocolonhere")
+ if err == nil {
+ t.Error("expected error for client ID without colon")
+ }
+}
+
+func TestParseClientID_MultipleColons(t *testing.T) {
+ tenant, client, err := ParseClientID("t1:c1:extra")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if tenant != "t1" {
+ t.Errorf("expected tenant t1, got %s", tenant)
+ }
+ if client != "c1:extra" {
+ t.Errorf("expected client c1:extra, got %s", client)
+ }
+}
+
+func TestParseClientID_Empty(t *testing.T) {
+ _, _, err := ParseClientID("")
+ if err == nil {
+ t.Error("expected error for empty client ID")
+ }
+}
+
+func TestParseClientID_Valid(t *testing.T) {
+ tenant, client, err := ParseClientID("tenant1:webapp")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if tenant != "tenant1" {
+ t.Errorf("expected tenant1, got %s", tenant)
+ }
+ if client != "webapp" {
+ t.Errorf("expected webapp, got %s", client)
+ }
+}
+
+func TestParseClientID_EmptyTenant(t *testing.T) {
+ _, _, err := ParseClientID(":client")
+ if err == nil {
+ t.Error("expected error for empty tenant")
+ }
+}
+
+func TestParseClientID_EmptyClient(t *testing.T) {
+ _, _, err := ParseClientID("tenant:")
+ if err == nil {
+ t.Error("expected error for empty client")
+ }
+}
+
+func TestAuthError_Interface(t *testing.T) {
+ err := &AuthError{Code: "unauthorized", Message: "test", Status: 401}
+ if err.Error() != "test" {
+ t.Errorf("expected 'test', got %s", err.Error())
+ }
+}
diff --git a/signer/internal/auth/oidc_validator.go b/signer/internal/auth/oidc_validator.go
new file mode 100644
index 000000000..b5b8632d3
--- /dev/null
+++ b/signer/internal/auth/oidc_validator.go
@@ -0,0 +1,296 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package auth
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v2/jwk"
+ "github.com/lestrrat-go/jwx/v2/jwt"
+ "golang.org/x/sync/singleflight"
+
+ "github.com/apache/airavata-custos/signer/internal/config"
+)
+
+type UserIdentity struct {
+ Issuer string
+ Subject string
+ Email string
+ Principal string // extracted label for logging
+}
+
+type OIDCValidator struct {
+ enabled bool
+ defaultEmail string
+ allowedIssuers []string
+ cacheTTL time.Duration
+ maxProviders int
+ httpTimeout time.Duration
+ httpClient *http.Client
+
+ mu sync.RWMutex
+ cache map[string]*jwksEntry
+ sfGroup singleflight.Group
+ // JWKSFetcher can be overridden for testing
+ JWKSFetcher func(ctx context.Context, issuer string) (jwk.Set, error)
+}
+
+type jwksEntry struct {
+ set jwk.Set
+ fetchedAt time.Time
+}
+
+func NewOIDCValidator(cfg config.AuthConfig, devMode config.DevModeConfig) *OIDCValidator {
+ enabled := !devMode.Enabled
+ defaultEmail := devMode.DefaultEmail
+ if defaultEmail == "" {
+ defaultEmail = "dev@localhost"
+ }
+
+ v := &OIDCValidator{
+ enabled: enabled,
+ defaultEmail: defaultEmail,
+ allowedIssuers: cfg.AllowedIssuers,
+ cacheTTL: time.Duration(cfg.OIDC.JWKSCacheTTLSeconds) * time.Second,
+ maxProviders: cfg.OIDC.JWKSMaxProviders,
+ httpTimeout: time.Duration(cfg.OIDC.TimeoutSeconds) * time.Second,
+ cache: make(map[string]*jwksEntry),
+ }
+ if v.cacheTTL == 0 {
+ v.cacheTTL = 5 * time.Minute
+ }
+ if v.maxProviders == 0 {
+ v.maxProviders = 10
+ }
+ if v.httpTimeout == 0 {
+ v.httpTimeout = 10 * time.Second
+ }
+ v.httpClient = &http.Client{Timeout: v.httpTimeout}
+ return v
+}
+
+// ValidateAccessToken returns a default identity when dev mode is active;
+// otherwise performs full OIDC token validation via JWKS.
+func (v *OIDCValidator) ValidateAccessToken(ctx context.Context, tokenString string) (*UserIdentity, error) {
+ if !v.enabled {
+ return &UserIdentity{
+ Issuer: "dev-mode",
+ Subject: "dev-user",
+ Email: v.defaultEmail,
+ Principal: "dev-user",
+ }, nil
+ }
+
+ if tokenString == "" {
+ return nil, &AuthError{Code: "invalid_token", Message: "Missing access token", Status: 401}
+ }
+
+ // Parse without verification first to get issuer
+ insecureToken, err := jwt.Parse([]byte(tokenString), jwt.WithVerify(false), jwt.WithValidate(false))
+ if err != nil {
+ return nil, &AuthError{Code: "invalid_token", Message: fmt.Sprintf("Failed to parse token: %v", err), Status: 401}
+ }
+
+ issuer := insecureToken.Issuer()
+ if issuer == "" {
+ return nil, &AuthError{Code: "invalid_token", Message: "Token missing issuer claim", Status: 401}
+ }
+
+ if len(v.allowedIssuers) > 0 {
+ allowed := false
+ for _, ai := range v.allowedIssuers {
+ if ai == issuer {
+ allowed = true
+ break
+ }
+ }
+ if !allowed {
+ return nil, &AuthError{Code: "invalid_token", Message: "Issuer not allowed", Status: 401}
+ }
+ }
+
+ keySet, err := v.getJWKS(ctx, issuer)
+ if err != nil {
+ return nil, &AuthError{Code: "invalid_token", Message: fmt.Sprintf("Failed to fetch JWKS: %v", err), Status: 401}
+ }
+
+ // Parse, verify signature, and validate claims in a single pass
+ token, err := jwt.Parse([]byte(tokenString),
+ jwt.WithKeySet(keySet),
+ jwt.WithValidate(true),
+ )
+ if err != nil {
+ errMsg := err.Error()
+ if containsAny(errMsg, "exp", "expired") {
+ return nil, &AuthError{Code: "invalid_token", Message: "Token has expired", Status: 401}
+ }
+ if containsAny(errMsg, "nbf", "not yet valid") {
+ return nil, &AuthError{Code: "invalid_token", Message: "Token is not yet valid", Status: 401}
+ }
+ return nil, &AuthError{Code: "invalid_token", Message: fmt.Sprintf("Token validation failed: %v", err), Status: 401}
+ }
+
+ principal := extractPrincipal(token)
+ if principal == "" {
+ return nil, &AuthError{Code: "invalid_token", Message: "No valid principal found in token claims", Status: 401}
+ }
+
+ var email string
+ if v, ok := token.Get("email"); ok {
+ if s, ok := v.(string); ok {
+ email = s
+ }
+ }
+
+ return &UserIdentity{
+ Issuer: issuer,
+ Subject: token.Subject(),
+ Email: email,
+ Principal: principal,
+ }, nil
+}
+
+func extractPrincipal(token jwt.Token) string {
+ // Priority: sub, preferred_username, email, username
+ if s := token.Subject(); s != "" {
+ return s
+ }
+ if v, ok := token.Get("preferred_username"); ok {
+ if s, ok := v.(string); ok && s != "" {
+ return s
+ }
+ }
+ if v, ok := token.Get("email"); ok {
+ if s, ok := v.(string); ok && s != "" {
+ return s
+ }
+ }
+ if v, ok := token.Get("username"); ok {
+ if s, ok := v.(string); ok && s != "" {
+ return s
+ }
+ }
+ return ""
+}
+
+func (v *OIDCValidator) getJWKS(ctx context.Context, issuer string) (jwk.Set, error) {
+ v.mu.RLock()
+ entry, ok := v.cache[issuer]
+ v.mu.RUnlock()
+
+ if ok && time.Since(entry.fetchedAt) < v.cacheTTL {
+ return entry.set, nil
+ }
+
+ // Use singleflight to coalesce concurrent fetches for the same issuer
+ result, err, _ := v.sfGroup.Do(issuer, func() (interface{}, error) {
+ // Double-check cache after acquiring singleflight slot
+ v.mu.RLock()
+ entry2, ok2 := v.cache[issuer]
+ v.mu.RUnlock()
+ if ok2 && time.Since(entry2.fetchedAt) < v.cacheTTL {
+ return entry2.set, nil
+ }
+
+ var keySet jwk.Set
+ var fetchErr error
+ if v.JWKSFetcher != nil {
+ keySet, fetchErr = v.JWKSFetcher(ctx, issuer)
+ } else {
+ keySet, fetchErr = v.fetchJWKSFromIssuer(ctx, issuer)
+ }
+ if fetchErr != nil {
+ return nil, fetchErr
+ }
+
+ v.mu.Lock()
+ defer v.mu.Unlock()
+
+ // Evict oldest if at capacity
+ if len(v.cache) >= v.maxProviders {
+ var oldestKey string
+ var oldestTime time.Time
+ for k, e := range v.cache {
+ if oldestKey == "" || e.fetchedAt.Before(oldestTime) {
+ oldestKey = k
+ oldestTime = e.fetchedAt
+ }
+ }
+ delete(v.cache, oldestKey)
+ }
+
+ v.cache[issuer] = &jwksEntry{set: keySet, fetchedAt: time.Now()}
+ return keySet, nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return result.(jwk.Set), nil
+}
+
+func (v *OIDCValidator) fetchJWKSFromIssuer(ctx context.Context, issuer string) (jwk.Set, error) {
+ ctx2, cancel := context.WithTimeout(ctx, v.httpTimeout)
+ defer cancel()
+
+ // Try standard OIDC discovery to get jwks_uri
+ discoveryURL := issuer + "/.well-known/openid-configuration"
+ req, err := http.NewRequestWithContext(ctx2, http.MethodGet, discoveryURL, nil)
+ if err == nil {
+ resp, err := v.httpClient.Do(req)
+ if err == nil && resp.StatusCode == 200 {
+ var discovery struct {
+ JWKSURI string `json:"jwks_uri"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&discovery); err == nil && discovery.JWKSURI != "" {
+ resp.Body.Close()
+ keySet, err := jwk.Fetch(ctx2, discovery.JWKSURI)
+ if err == nil {
+ return keySet, nil
+ }
+ } else {
+ resp.Body.Close()
+ }
+ } else if resp != nil {
+ resp.Body.Close()
+ }
+ }
+
+ // Fallback: try standard JWKS URI
+ keySet, err := jwk.Fetch(ctx2, issuer+"/.well-known/jwks.json")
+ if err != nil {
+ // Try certs endpoint (Google-style)
+ keySet, err = jwk.Fetch(ctx2, issuer+"/certs")
+ if err != nil {
+ return nil, fmt.Errorf("fetching JWKS from %s: %w", issuer, err)
+ }
+ }
+ return keySet, nil
+}
+
+func containsAny(s string, substrs ...string) bool {
+ for _, sub := range substrs {
+ if strings.Contains(s, sub) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/signer/internal/auth/oidc_validator_test.go b/signer/internal/auth/oidc_validator_test.go
new file mode 100644
index 000000000..c399a51fe
--- /dev/null
+++ b/signer/internal/auth/oidc_validator_test.go
@@ -0,0 +1,457 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package auth
+
+import (
+ "context"
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/ed25519"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v2/jwa"
+ "github.com/lestrrat-go/jwx/v2/jwk"
+ "github.com/lestrrat-go/jwx/v2/jwt"
+
+ "github.com/apache/airavata-custos/signer/internal/config"
+)
+
+func createTestJWT(t *testing.T, key crypto.Signer, alg jwa.SignatureAlgorithm, claims map[string]interface{}) string {
+ t.Helper()
+
+ tok := jwt.New()
+ for k, v := range claims {
+ tok.Set(k, v)
+ }
+
+ jwkKey, err := jwk.FromRaw(key)
+ if err != nil {
+ t.Fatalf("creating JWK from key: %v", err)
+ }
+ jwkKey.Set(jwk.KeyIDKey, "test-key-1")
+ jwkKey.Set(jwk.AlgorithmKey, alg)
+
+ signed, err := jwt.Sign(tok, jwt.WithKey(alg, jwkKey))
+ if err != nil {
+ t.Fatalf("signing JWT: %v", err)
+ }
+ return string(signed)
+}
+
+func createJWKSServer(t *testing.T, pubKey crypto.PublicKey, alg jwa.SignatureAlgorithm, requestCount *atomic.Int32) *httptest.Server {
+ t.Helper()
+
+ jwkKey, err := jwk.FromRaw(pubKey)
+ if err != nil {
+ t.Fatalf("creating JWK: %v", err)
+ }
+ jwkKey.Set(jwk.KeyIDKey, "test-key-1")
+ jwkKey.Set(jwk.AlgorithmKey, alg)
+
+ set := jwk.NewSet()
+ set.AddKey(jwkKey)
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) {
+ if requestCount != nil {
+ requestCount.Add(1)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(set)
+ })
+
+ return httptest.NewServer(mux)
+}
+
+func newTestValidator(allowedIssuers []string) *OIDCValidator {
+ return NewOIDCValidator(config.AuthConfig{
+ AllowedIssuers: allowedIssuers,
+ OIDC: config.OIDCConfig{
+ JWKSCacheTTLSeconds: 300,
+ JWKSMaxProviders: 10,
+ TimeoutSeconds: 10,
+ },
+ }, config.DevModeConfig{})
+}
+
+func TestOIDCValidator_DevMode(t *testing.T) {
+ v := NewOIDCValidator(config.AuthConfig{}, config.DevModeConfig{
+ Enabled: true,
+ DefaultEmail: "test@dev.local",
+ })
+
+ identity, err := v.ValidateAccessToken(context.Background(), "garbage-token")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if identity.Issuer != "dev-mode" {
+ t.Errorf("expected issuer 'dev-mode', got %s", identity.Issuer)
+ }
+ if identity.Subject != "dev-user" {
+ t.Errorf("expected subject 'dev-user', got %s", identity.Subject)
+ }
+ if identity.Email != "test@dev.local" {
+ t.Errorf("expected email 'test@dev.local', got %s", identity.Email)
+ }
+ if identity.Principal != "dev-user" {
+ t.Errorf("expected principal 'dev-user', got %s", identity.Principal)
+ }
+}
+
+func TestOIDCValidator_DevModeDefaultEmail(t *testing.T) {
+ v := NewOIDCValidator(config.AuthConfig{}, config.DevModeConfig{
+ Enabled: true,
+ })
+
+ identity, err := v.ValidateAccessToken(context.Background(), "any-token")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if identity.Email != "dev@localhost" {
+ t.Errorf("expected email 'dev@localhost', got %s", identity.Email)
+ }
+}
+
+func TestOIDCValidator_ValidRS256Token(t *testing.T) {
+ rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var reqCount atomic.Int32
+ server := createJWKSServer(t, &rsaKey.PublicKey, jwa.RS256, &reqCount)
+ defer server.Close()
+
+ v := newTestValidator(nil)
+ v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+ return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+ }
+
+ token := createTestJWT(t, rsaKey, jwa.RS256, map[string]interface{}{
+ "iss": server.URL,
+ "sub": "user123",
+ "exp": time.Now().Add(time.Hour).Unix(),
+ })
+
+ identity, err := v.ValidateAccessToken(context.Background(), token)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if identity.Subject != "user123" {
+ t.Errorf("expected subject user123, got %s", identity.Subject)
+ }
+}
+
+func TestOIDCValidator_ValidES256Token(t *testing.T) {
+ ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ server := createJWKSServer(t, &ecKey.PublicKey, jwa.ES256, nil)
+ defer server.Close()
+
+ v := newTestValidator(nil)
+ v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+ return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+ }
+
+ token := createTestJWT(t, ecKey, jwa.ES256, map[string]interface{}{
+ "iss": server.URL,
+ "sub": "ecuser",
+ "exp": time.Now().Add(time.Hour).Unix(),
+ })
+
+ identity, err := v.ValidateAccessToken(context.Background(), token)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if identity.Subject != "ecuser" {
+ t.Errorf("expected subject ecuser, got %s", identity.Subject)
+ }
+}
+
+func TestOIDCValidator_ValidEdDSAToken(t *testing.T) {
+ _, edKey, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ server := createJWKSServer(t, edKey.Public(), jwa.EdDSA, nil)
+ defer server.Close()
+
+ v := newTestValidator(nil)
+ v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+ return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+ }
+
+ token := createTestJWT(t, edKey, jwa.EdDSA, map[string]interface{}{
+ "iss": server.URL,
+ "sub": "eduser",
+ "exp": time.Now().Add(time.Hour).Unix(),
+ })
+
+ identity, err := v.ValidateAccessToken(context.Background(), token)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if identity.Subject != "eduser" {
+ t.Errorf("expected subject eduser, got %s", identity.Subject)
+ }
+}
+
+func TestOIDCValidator_ExpiredToken(t *testing.T) {
+ rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+ server := createJWKSServer(t, &rsaKey.PublicKey, jwa.RS256, nil)
+ defer server.Close()
+
+ v := newTestValidator(nil)
+ v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+ return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+ }
+
+ token := createTestJWT(t, rsaKey, jwa.RS256, map[string]interface{}{
+ "iss": server.URL,
+ "sub": "user",
+ "exp": time.Now().Add(-time.Hour).Unix(),
+ })
+
+ _, err := v.ValidateAccessToken(context.Background(), token)
+ if err == nil {
+ t.Fatal("expected error for expired token")
+ }
+ authErr, ok := err.(*AuthError)
+ if !ok {
+ t.Fatalf("expected AuthError, got %T", err)
+ }
+ if authErr.Code != "invalid_token" {
+ t.Errorf("expected code invalid_token, got %s", authErr.Code)
+ }
+}
+
+func TestOIDCValidator_FutureNBF(t *testing.T) {
+ rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+ server := createJWKSServer(t, &rsaKey.PublicKey, jwa.RS256, nil)
+ defer server.Close()
+
+ v := newTestValidator(nil)
+ v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+ return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+ }
+
+ token := createTestJWT(t, rsaKey, jwa.RS256, map[string]interface{}{
+ "iss": server.URL,
+ "sub": "user",
+ "exp": time.Now().Add(2 * time.Hour).Unix(),
+ "nbf": time.Now().Add(time.Hour).Unix(),
+ })
+
+ _, err := v.ValidateAccessToken(context.Background(), token)
+ if err == nil {
+ t.Fatal("expected error for future nbf token")
+ }
+}
+
+func TestOIDCValidator_InvalidSignature(t *testing.T) {
+ rsaKey1, _ := rsa.GenerateKey(rand.Reader, 2048)
+ rsaKey2, _ := rsa.GenerateKey(rand.Reader, 2048)
+
+ // JWKS serves key2's public key, but token signed with key1
+ server := createJWKSServer(t, &rsaKey2.PublicKey, jwa.RS256, nil)
+ defer server.Close()
+
+ v := newTestValidator(nil)
+ v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+ return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+ }
+
+ token := createTestJWT(t, rsaKey1, jwa.RS256, map[string]interface{}{
+ "iss": server.URL,
+ "sub": "user",
+ "exp": time.Now().Add(time.Hour).Unix(),
+ })
+
+ _, err := v.ValidateAccessToken(context.Background(), token)
+ if err == nil {
+ t.Fatal("expected error for invalid signature")
+ }
+}
+
+func TestOIDCValidator_PrincipalFromSub(t *testing.T) {
+ rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+ server := createJWKSServer(t, &rsaKey.PublicKey, jwa.RS256, nil)
+ defer server.Close()
+
+ v := newTestValidator(nil)
+ v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+ return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+ }
+
+ token := createTestJWT(t, rsaKey, jwa.RS256, map[string]interface{}{
+ "iss": server.URL,
+ "sub": "user123",
+ "email": "user@example.com",
+ "exp": time.Now().Add(time.Hour).Unix(),
+ })
+
+ identity, err := v.ValidateAccessToken(context.Background(), token)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if identity.Principal != "user123" {
+ t.Errorf("expected principal user123, got %s", identity.Principal)
+ }
+}
+
+func TestOIDCValidator_EmptySubject(t *testing.T) {
+ rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+ server := createJWKSServer(t, &rsaKey.PublicKey, jwa.RS256, nil)
+ defer server.Close()
+
+ v := newTestValidator(nil)
+ v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+ return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+ }
+
+ token := createTestJWT(t, rsaKey, jwa.RS256, map[string]interface{}{
+ "iss": server.URL,
+ "sub": "",
+ "exp": time.Now().Add(time.Hour).Unix(),
+ })
+
+ _, err := v.ValidateAccessToken(context.Background(), token)
+ if err == nil {
+ t.Fatal("expected error for empty subject")
+ }
+ authErr, ok := err.(*AuthError)
+ if !ok {
+ t.Fatalf("expected AuthError, got %T", err)
+ }
+ if authErr.Message != "No valid principal found in token claims" {
+ t.Errorf("expected 'No valid principal found' message, got %s", authErr.Message)
+ }
+}
+
+func TestOIDCValidator_MissingIssuer(t *testing.T) {
+ rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+
+ v := newTestValidator(nil)
+
+ token := createTestJWT(t, rsaKey, jwa.RS256, map[string]interface{}{
+ "sub": "user",
+ "exp": time.Now().Add(time.Hour).Unix(),
+ })
+
+ _, err := v.ValidateAccessToken(context.Background(), token)
+ if err == nil {
+ t.Fatal("expected error for missing issuer")
+ }
+}
+
+func TestOIDCValidator_JWKSCaching(t *testing.T) {
+ rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+
+ var fetchCount atomic.Int32
+ server := createJWKSServer(t, &rsaKey.PublicKey, jwa.RS256, nil)
+ defer server.Close()
+
+ v := newTestValidator(nil)
+ v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+ fetchCount.Add(1)
+ return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+ }
+
+ for i := 0; i < 5; i++ {
+ token := createTestJWT(t, rsaKey, jwa.RS256, map[string]interface{}{
+ "iss": server.URL,
+ "sub": fmt.Sprintf("user%d", i),
+ "exp": time.Now().Add(time.Hour).Unix(),
+ })
+ _, err := v.ValidateAccessToken(context.Background(), token)
+ if err != nil {
+ t.Fatalf("request %d: unexpected error: %v", i, err)
+ }
+ }
+
+ if fetchCount.Load() != 1 {
+ t.Errorf("expected 1 JWKS fetch, got %d", fetchCount.Load())
+ }
+}
+
+func TestOIDCValidator_DisallowedIssuer(t *testing.T) {
+ rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+ server := createJWKSServer(t, &rsaKey.PublicKey, jwa.RS256, nil)
+ defer server.Close()
+
+ v := newTestValidator([]string{"https://allowed.example.com"})
+ v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+ return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+ }
+
+ token := createTestJWT(t, rsaKey, jwa.RS256, map[string]interface{}{
+ "iss": "https://other.example.com",
+ "sub": "user",
+ "exp": time.Now().Add(time.Hour).Unix(),
+ })
+
+ _, err := v.ValidateAccessToken(context.Background(), token)
+ if err == nil {
+ t.Fatal("expected error for disallowed issuer")
+ }
+ authErr := err.(*AuthError)
+ if authErr.Message != "Issuer not allowed" {
+ t.Errorf("expected 'Issuer not allowed', got %s", authErr.Message)
+ }
+}
+
+func TestOIDCValidator_EmptyAllowedIssuersAcceptsAll(t *testing.T) {
+ rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+ server := createJWKSServer(t, &rsaKey.PublicKey, jwa.RS256, nil)
+ defer server.Close()
+
+ v := newTestValidator([]string{})
+ v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+ return jwk.Fetch(ctx, server.URL+"/.well-known/jwks.json")
+ }
+
+ token := createTestJWT(t, rsaKey, jwa.RS256, map[string]interface{}{
+ "iss": "https://any-issuer.example.com",
+ "sub": "user",
+ "exp": time.Now().Add(time.Hour).Unix(),
+ })
+
+ _, err := v.ValidateAccessToken(context.Background(), token)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestOIDCValidator_EmptyToken(t *testing.T) {
+ v := newTestValidator(nil)
+ _, err := v.ValidateAccessToken(context.Background(), "")
+ if err == nil {
+ t.Error("expected error for empty token")
+ }
+}
diff --git a/signer/internal/cert/rsa_ecdsa_signing_test.go b/signer/internal/cert/rsa_ecdsa_signing_test.go
new file mode 100644
index 000000000..233e817ea
--- /dev/null
+++ b/signer/internal/cert/rsa_ecdsa_signing_test.go
@@ -0,0 +1,210 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cert
+
+import (
+ "crypto/ecdsa"
+ "crypto/ed25519"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/pem"
+ "testing"
+
+ "golang.org/x/crypto/ssh"
+)
+
+func generateRSACA(t *testing.T) []byte {
+ t.Helper()
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ privBytes, err := x509.MarshalPKCS8PrivateKey(key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
+}
+
+func generateECDSACA(t *testing.T) []byte {
+ t.Helper()
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ privBytes, err := x509.MarshalPKCS8PrivateKey(key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
+}
+
+func generateRSAUserKey(t *testing.T) ssh.PublicKey {
+ t.Helper()
+ key, _ := rsa.GenerateKey(rand.Reader, 2048)
+ sshPub, _ := ssh.NewPublicKey(&key.PublicKey)
+ return sshPub
+}
+
+func generateECDSAUserKey(t *testing.T) ssh.PublicKey {
+ t.Helper()
+ key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ sshPub, _ := ssh.NewPublicKey(&key.PublicKey)
+ return sshPub
+}
+
+func TestSignCertificate_RSAKeyWithRSACA(t *testing.T) {
+ caPEM := generateRSACA(t)
+ userKey := generateRSAUserKey(t)
+
+ result, err := SignCertificate(&SignRequest{
+ PublicKey: userKey,
+ CAPrivateKeyPEM: caPEM,
+ Serial: 10,
+ Principal: "rsauser",
+ ClientID: "c1",
+ TTLSeconds: 3600,
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ parsedKey, err := ssh.ParsePublicKey(result.CertBytes)
+ if err != nil {
+ t.Fatalf("failed to parse cert: %v", err)
+ }
+ cert := parsedKey.(*ssh.Certificate)
+ if cert.Serial != 10 {
+ t.Errorf("expected serial 10, got %d", cert.Serial)
+ }
+}
+
+func TestSignCertificate_ECDSAKeyWithECDSACA(t *testing.T) {
+ caPEM := generateECDSACA(t)
+ userKey := generateECDSAUserKey(t)
+
+ result, err := SignCertificate(&SignRequest{
+ PublicKey: userKey,
+ CAPrivateKeyPEM: caPEM,
+ Serial: 20,
+ Principal: "ecuser",
+ ClientID: "c1",
+ TTLSeconds: 3600,
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ parsedKey, err := ssh.ParsePublicKey(result.CertBytes)
+ if err != nil {
+ t.Fatalf("failed to parse cert: %v", err)
+ }
+ cert := parsedKey.(*ssh.Certificate)
+ if cert.Serial != 20 {
+ t.Errorf("expected serial 20, got %d", cert.Serial)
+ }
+}
+
+// Sign an Ed25519 user key with an RSA CA
+func TestSignCertificate_Ed25519KeyWithRSACA(t *testing.T) {
+ caPEM := generateRSACA(t)
+ pub, _, _ := ed25519.GenerateKey(rand.Reader)
+ sshPub, _ := ssh.NewPublicKey(pub)
+
+ result, err := SignCertificate(&SignRequest{
+ PublicKey: sshPub,
+ CAPrivateKeyPEM: caPEM,
+ Serial: 30,
+ Principal: "crossuser",
+ ClientID: "c1",
+ TTLSeconds: 3600,
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ parsedKey, err := ssh.ParsePublicKey(result.CertBytes)
+ if err != nil {
+ t.Fatalf("failed to parse cert: %v", err)
+ }
+ cert := parsedKey.(*ssh.Certificate)
+ if cert.Serial != 30 {
+ t.Errorf("expected serial 30, got %d", cert.Serial)
+ }
+}
+
+// Sign an Ed25519 user key with ECDSA CA
+func TestSignCertificate_Ed25519KeyWithECDSACA(t *testing.T) {
+ caPEM := generateECDSACA(t)
+ pub, _, _ := ed25519.GenerateKey(rand.Reader)
+ sshPub, _ := ssh.NewPublicKey(pub)
+
+ result, err := SignCertificate(&SignRequest{
+ PublicKey: sshPub,
+ CAPrivateKeyPEM: caPEM,
+ Serial: 40,
+ Principal: "ecdsacauser",
+ ClientID: "c1",
+ TTLSeconds: 3600,
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if result.Serial != 40 {
+ t.Errorf("expected serial 40, got %d", result.Serial)
+ }
+}
+
+// Test JWK conversion for RSA and ECDSA keys
+func TestPublicKeyToJWK_RSA(t *testing.T) {
+ key, _ := rsa.GenerateKey(rand.Reader, 2048)
+ pubBytes, _ := x509.MarshalPKIXPublicKey(&key.PublicKey)
+ pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
+
+ jwkMap, err := PublicKeyToJWK(pubPEM)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if jwkMap["kty"] != "RSA" {
+ t.Errorf("expected kty RSA, got %v", jwkMap["kty"])
+ }
+ if jwkMap["n"] == nil {
+ t.Error("expected n field")
+ }
+ if jwkMap["e"] == nil {
+ t.Error("expected e field")
+ }
+}
+
+func TestPublicKeyToJWK_ECDSA(t *testing.T) {
+ key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ pubBytes, _ := x509.MarshalPKIXPublicKey(&key.PublicKey)
+ pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
+
+ jwkMap, err := PublicKeyToJWK(pubPEM)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if jwkMap["kty"] != "EC" {
+ t.Errorf("expected kty EC, got %v", jwkMap["kty"])
+ }
+ if jwkMap["crv"] != "P-256" {
+ t.Errorf("expected crv P-256, got %v", jwkMap["crv"])
+ }
+}
diff --git a/signer/internal/cert/signer.go b/signer/internal/cert/signer.go
new file mode 100644
index 000000000..173e162a9
--- /dev/null
+++ b/signer/internal/cert/signer.go
@@ -0,0 +1,110 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cert
+
+import (
+ "crypto/rand"
+ "encoding/binary"
+ "fmt"
+ "time"
+
+ "golang.org/x/crypto/ssh"
+)
+
+type SignRequest struct {
+ PublicKey ssh.PublicKey
+ CAPrivateKeyPEM []byte
+ Serial uint64
+ Principal string
+ ClientID string
+ TTLSeconds uint64
+ Extensions map[string]string
+ CriticalOptions map[string]string
+}
+
+type SignResult struct {
+ CertBytes []byte // raw certificate bytes (for base64 encoding)
+ Serial uint64
+ ValidAfter uint64
+ ValidBefore uint64
+ CAFingerprint string
+ KeyID string
+}
+
+func SignCertificate(req *SignRequest) (*SignResult, error) {
+ _, sshSigner, err := ParseCAPrivateKey(req.CAPrivateKeyPEM)
+ if err != nil {
+ return nil, fmt.Errorf("parsing CA key: %w", err)
+ }
+
+ nonce := make([]byte, 32)
+ if _, err := rand.Read(nonce); err != nil {
+ return nil, fmt.Errorf("generating nonce: %w", err)
+ }
+
+ now := time.Now().UTC()
+ validAfter := uint64(now.Unix())
+ validBefore := uint64(now.Unix()) + req.TTLSeconds
+
+ keyID := fmt.Sprintf("%s@%s-%d", req.Principal, req.ClientID, now.Unix())
+
+ extensions := req.Extensions
+ if extensions == nil {
+ extensions = map[string]string{
+ "permit-pty": "",
+ "permit-port-forwarding": "",
+ "permit-user-rc": "",
+ }
+ }
+
+ cert := &ssh.Certificate{
+ Nonce: nonce,
+ Key: req.PublicKey,
+ Serial: req.Serial,
+ CertType: ssh.UserCert,
+ KeyId: keyID,
+ ValidPrincipals: []string{req.Principal},
+ ValidAfter: validAfter,
+ ValidBefore: validBefore,
+ Permissions: ssh.Permissions{
+ CriticalOptions: req.CriticalOptions,
+ Extensions: extensions,
+ },
+ }
+
+ if err := cert.SignCert(rand.Reader, sshSigner); err != nil {
+ return nil, fmt.Errorf("signing certificate: %w", err)
+ }
+
+ caFingerprint := ssh.FingerprintSHA256(sshSigner.PublicKey())
+
+ return &SignResult{
+ CertBytes: cert.Marshal(),
+ Serial: req.Serial,
+ ValidAfter: validAfter,
+ ValidBefore: validBefore,
+ CAFingerprint: caFingerprint,
+ KeyID: keyID,
+ }, nil
+}
+
+func GenerateRandomSerial() (uint64, error) {
+ var b [8]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return 0, err
+ }
+ return binary.BigEndian.Uint64(b[:]), nil
+}
diff --git a/signer/internal/cert/signer_test.go b/signer/internal/cert/signer_test.go
new file mode 100644
index 000000000..d17f26fe8
--- /dev/null
+++ b/signer/internal/cert/signer_test.go
@@ -0,0 +1,246 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cert
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/pem"
+ "testing"
+ "time"
+
+ "golang.org/x/crypto/ssh"
+)
+
+func generateTestEd25519CA(t *testing.T) ([]byte, ssh.PublicKey) {
+ t.Helper()
+ pub, priv, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
+ if err != nil {
+ t.Fatal(err)
+ }
+ privPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
+
+ sshPub, err := ssh.NewPublicKey(pub)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return privPEM, sshPub
+}
+
+func generateTestEd25519UserKey(t *testing.T) ssh.PublicKey {
+ t.Helper()
+ pub, _, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sshPub, err := ssh.NewPublicKey(pub)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return sshPub
+}
+
+func TestSignCertificate_Ed25519(t *testing.T) {
+ caPrivPEM, _ := generateTestEd25519CA(t)
+ userKey := generateTestEd25519UserKey(t)
+
+ before := time.Now().UTC()
+ result, err := SignCertificate(&SignRequest{
+ PublicKey: userKey,
+ CAPrivateKeyPEM: caPrivPEM,
+ Serial: 42,
+ Principal: "testuser",
+ ClientID: "webapp",
+ TTLSeconds: 7200,
+ })
+ after := time.Now().UTC()
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Check serial
+ if result.Serial != 42 {
+ t.Errorf("expected serial 42, got %d", result.Serial)
+ }
+
+ // Check validity window
+ if int64(result.ValidAfter) < before.Unix()-1 || int64(result.ValidAfter) > after.Unix()+1 {
+ t.Errorf("valid_after %d not within expected range [%d, %d]", result.ValidAfter, before.Unix(), after.Unix())
+ }
+ expectedBefore := result.ValidAfter + 7200
+ if result.ValidBefore != expectedBefore {
+ t.Errorf("expected valid_before %d, got %d", expectedBefore, result.ValidBefore)
+ }
+
+ // Check CA fingerprint
+ if result.CAFingerprint == "" {
+ t.Error("expected non-empty CA fingerprint")
+ }
+
+ // Parse the cert and validate internals
+ parsedKey, err := ssh.ParsePublicKey(result.CertBytes)
+ if err != nil {
+ t.Fatalf("failed to parse certificate: %v", err)
+ }
+ cert, ok := parsedKey.(*ssh.Certificate)
+ if !ok {
+ t.Fatal("parsed key is not a certificate")
+ }
+
+ if cert.Serial != 42 {
+ t.Errorf("cert serial: expected 42, got %d", cert.Serial)
+ }
+
+ if len(cert.ValidPrincipals) != 1 || cert.ValidPrincipals[0] != "testuser" {
+ t.Errorf("expected principals [testuser], got %v", cert.ValidPrincipals)
+ }
+
+ // User cert type
+ if cert.CertType != ssh.UserCert {
+ t.Errorf("expected UserCert type, got %d", cert.CertType)
+ }
+
+ for _, ext := range []string{"permit-pty", "permit-port-forwarding", "permit-user-rc"} {
+ if _, ok := cert.Extensions[ext]; !ok {
+ t.Errorf("expected extension %s", ext)
+ }
+ }
+
+ if len(cert.Nonce) != 32 {
+ t.Errorf("expected 32-byte nonce, got %d", len(cert.Nonce))
+ }
+}
+
+func TestSignCertificate_UniqueNonces(t *testing.T) {
+ caPrivPEM, _ := generateTestEd25519CA(t)
+ userKey := generateTestEd25519UserKey(t)
+
+ var nonces [][]byte
+ for i := 0; i < 10; i++ {
+ result, err := SignCertificate(&SignRequest{
+ PublicKey: userKey,
+ CAPrivateKeyPEM: caPrivPEM,
+ Serial: uint64(i + 1),
+ Principal: "user",
+ ClientID: "c1",
+ TTLSeconds: 3600,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ parsedKey, _ := ssh.ParsePublicKey(result.CertBytes)
+ cert := parsedKey.(*ssh.Certificate)
+ nonces = append(nonces, cert.Nonce)
+ }
+
+ // Check all nonces are unique
+ for i := 0; i < len(nonces); i++ {
+ for j := i + 1; j < len(nonces); j++ {
+ if string(nonces[i]) == string(nonces[j]) {
+ t.Errorf("nonces %d and %d are identical", i, j)
+ }
+ }
+ }
+}
+
+func TestSignCertificate_KeyIDFormat(t *testing.T) {
+ caPrivPEM, _ := generateTestEd25519CA(t)
+ userKey := generateTestEd25519UserKey(t)
+
+ result, err := SignCertificate(&SignRequest{
+ PublicKey: userKey,
+ CAPrivateKeyPEM: caPrivPEM,
+ Serial: 1,
+ Principal: "jdoe",
+ ClientID: "webapp",
+ TTLSeconds: 3600,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ parsedKey, _ := ssh.ParsePublicKey(result.CertBytes)
+ cert := parsedKey.(*ssh.Certificate)
+
+ // Key ID should match jdoe@webapp-{timestamp}
+ if len(cert.KeyId) < len("jdoe@webapp-") {
+ t.Errorf("key ID too short: %s", cert.KeyId)
+ }
+ expected := "jdoe@webapp-"
+ if cert.KeyId[:len(expected)] != expected {
+ t.Errorf("key ID prefix: expected %s, got %s", expected, cert.KeyId[:len(expected)])
+ }
+}
+
+func TestSignCertificate_WithCriticalOptions(t *testing.T) {
+ caPrivPEM, _ := generateTestEd25519CA(t)
+ userKey := generateTestEd25519UserKey(t)
+
+ result, err := SignCertificate(&SignRequest{
+ PublicKey: userKey,
+ CAPrivateKeyPEM: caPrivPEM,
+ Serial: 1,
+ Principal: "user",
+ ClientID: "c1",
+ TTLSeconds: 3600,
+ CriticalOptions: map[string]string{"source-address": "10.0.0.0/8"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ parsedKey, _ := ssh.ParsePublicKey(result.CertBytes)
+ cert := parsedKey.(*ssh.Certificate)
+
+ if v, ok := cert.Permissions.CriticalOptions["source-address"]; !ok || v != "10.0.0.0/8" {
+ t.Errorf("expected critical option source-address=10.0.0.0/8, got %v", cert.Permissions.CriticalOptions)
+ }
+}
+
+func TestSignCertificate_Base64Decodable(t *testing.T) {
+ caPrivPEM, _ := generateTestEd25519CA(t)
+ userKey := generateTestEd25519UserKey(t)
+
+ result, err := SignCertificate(&SignRequest{
+ PublicKey: userKey,
+ CAPrivateKeyPEM: caPrivPEM,
+ Serial: 1,
+ Principal: "user",
+ ClientID: "c1",
+ TTLSeconds: 3600,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ encoded := base64.StdEncoding.EncodeToString(result.CertBytes)
+ decoded, err := base64.StdEncoding.DecodeString(encoded)
+ if err != nil {
+ t.Fatalf("base64 decode failed: %v", err)
+ }
+
+ _, err = ssh.ParsePublicKey(decoded)
+ if err != nil {
+ t.Fatalf("failed to parse decoded certificate: %v", err)
+ }
+}
diff --git a/signer/internal/cert/ssh_key.go b/signer/internal/cert/ssh_key.go
new file mode 100644
index 000000000..ff1b9b118
--- /dev/null
+++ b/signer/internal/cert/ssh_key.go
@@ -0,0 +1,237 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package cert handles SSH certificate construction and signing.
+package cert
+
+import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/ed25519"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/pem"
+ "fmt"
+ "io"
+ "strings"
+
+ "golang.org/x/crypto/ssh"
+)
+
+// ParseSSHPublicKey parses an SSH public key in authorized_keys format.
+func ParseSSHPublicKey(keyStr string) (ssh.PublicKey, error) {
+ keyStr = strings.TrimSpace(keyStr)
+ if keyStr == "" {
+ return nil, fmt.Errorf("empty public key")
+ }
+ pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyStr))
+ if err != nil {
+ return nil, fmt.Errorf("parsing SSH public key: %w", err)
+ }
+ return pubKey, nil
+}
+
+func NormalizeKeyType(sshKeyType string) string {
+ switch sshKeyType {
+ case "ssh-ed25519":
+ return "ed25519"
+ case "ssh-rsa":
+ return "rsa"
+ case "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521":
+ return "ecdsa"
+ default:
+ return sshKeyType
+ }
+}
+
+func SSHPublicKeyFingerprint(key ssh.PublicKey) string {
+ return ssh.FingerprintSHA256(key)
+}
+
+func ParseCAPrivateKey(pemData []byte) (crypto.Signer, ssh.Signer, error) {
+ block, _ := pem.Decode(pemData)
+ if block == nil {
+ return nil, nil, fmt.Errorf("failed to decode PEM block")
+ }
+
+ var signer crypto.Signer
+ switch block.Type {
+ case "PRIVATE KEY":
+ key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+ if err != nil {
+ return nil, nil, fmt.Errorf("parsing PKCS8 private key: %w", err)
+ }
+ var ok bool
+ signer, ok = key.(crypto.Signer)
+ if !ok {
+ return nil, nil, fmt.Errorf("private key does not implement crypto.Signer")
+ }
+ case "EC PRIVATE KEY":
+ key, err := x509.ParseECPrivateKey(block.Bytes)
+ if err != nil {
+ return nil, nil, fmt.Errorf("parsing EC private key: %w", err)
+ }
+ signer = key
+ case "RSA PRIVATE KEY":
+ key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
+ if err != nil {
+ return nil, nil, fmt.Errorf("parsing RSA private key: %w", err)
+ }
+ signer = key
+ default:
+ return nil, nil, fmt.Errorf("unsupported PEM block type: %s", block.Type)
+ }
+
+ var sshSigner ssh.Signer
+ var err error
+
+ // For RSA keys, use SHA-256 signer
+ if _, ok := signer.(*rsa.PrivateKey); ok {
+ sshSigner, err = ssh.NewSignerFromKey(signer)
+ if err != nil {
+ return nil, nil, fmt.Errorf("creating SSH signer: %w", err)
+ }
+ // Wrap to use rsa-sha2-256 algorithm
+ algorithmSigner, ok := sshSigner.(ssh.AlgorithmSigner)
+ if ok {
+ sshSigner = &rsaSHA256Signer{inner: algorithmSigner}
+ }
+ } else {
+ sshSigner, err = ssh.NewSignerFromKey(signer)
+ if err != nil {
+ return nil, nil, fmt.Errorf("creating SSH signer: %w", err)
+ }
+ }
+
+ return signer, sshSigner, nil
+}
+
+// rsaSHA256Signer wraps an SSH AlgorithmSigner to always use rsa-sha2-256.
+type rsaSHA256Signer struct {
+ inner ssh.AlgorithmSigner
+}
+
+func (s *rsaSHA256Signer) PublicKey() ssh.PublicKey {
+ return s.inner.PublicKey()
+}
+
+func (s *rsaSHA256Signer) Sign(rand io.Reader, data []byte) (*ssh.Signature, error) {
+ return s.inner.SignWithAlgorithm(rand, data, ssh.KeyAlgoRSASHA256)
+}
+
+func ParseCAPublicKey(pemData []byte) (ssh.PublicKey, error) {
+ block, _ := pem.Decode(pemData)
+ if block == nil {
+ return nil, fmt.Errorf("failed to decode PEM block")
+ }
+
+ pubKey, err := x509.ParsePKIXPublicKey(block.Bytes)
+ if err != nil {
+ return nil, fmt.Errorf("parsing public key: %w", err)
+ }
+
+ sshPub, err := ssh.NewPublicKey(pubKey)
+ if err != nil {
+ return nil, fmt.Errorf("converting to SSH public key: %w", err)
+ }
+ return sshPub, nil
+}
+
+// PEMToOpenSSH converts a PEM public key to OpenSSH format, suitable for trusted-user-ca-keys files.
+func PEMToOpenSSH(publicKeyPEM []byte) (string, error) {
+ sshPub, err := ParseCAPublicKey(publicKeyPEM)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub))), nil
+}
+
+func CAFingerprint(publicKeyPEM []byte) (string, error) {
+ sshPub, err := ParseCAPublicKey(publicKeyPEM)
+ if err != nil {
+ return "", err
+ }
+ return ssh.FingerprintSHA256(sshPub), nil
+}
+
+func PublicKeyToJWK(publicKeyPEM []byte) (map[string]interface{}, error) {
+ block, _ := pem.Decode(publicKeyPEM)
+ if block == nil {
+ return nil, fmt.Errorf("failed to decode PEM block")
+ }
+
+ pubKey, err := x509.ParsePKIXPublicKey(block.Bytes)
+ if err != nil {
+ return nil, fmt.Errorf("parsing public key: %w", err)
+ }
+
+ sshPub, err := ssh.NewPublicKey(pubKey)
+ if err != nil {
+ return nil, fmt.Errorf("converting to SSH public key: %w", err)
+ }
+ fingerprint := ssh.FingerprintSHA256(sshPub)
+
+ jwkMap := map[string]interface{}{
+ "kid": fingerprint,
+ }
+
+ switch key := pubKey.(type) {
+ case ed25519.PublicKey:
+ jwkMap["kty"] = "OKP"
+ jwkMap["crv"] = "Ed25519"
+ jwkMap["x"] = base64.RawURLEncoding.EncodeToString([]byte(key))
+ case *ecdsa.PublicKey:
+ jwkMap["kty"] = "EC"
+ switch key.Curve.Params().BitSize {
+ case 256:
+ jwkMap["crv"] = "P-256"
+ case 384:
+ jwkMap["crv"] = "P-384"
+ case 521:
+ jwkMap["crv"] = "P-521"
+ }
+ byteLen := (key.Curve.Params().BitSize + 7) / 8
+ xBytes := key.X.Bytes()
+ yBytes := key.Y.Bytes()
+ // Pad to full length
+ xPadded := make([]byte, byteLen)
+ yPadded := make([]byte, byteLen)
+ copy(xPadded[byteLen-len(xBytes):], xBytes)
+ copy(yPadded[byteLen-len(yBytes):], yBytes)
+ jwkMap["x"] = base64.RawURLEncoding.EncodeToString(xPadded)
+ jwkMap["y"] = base64.RawURLEncoding.EncodeToString(yPadded)
+ case *rsa.PublicKey:
+ jwkMap["kty"] = "RSA"
+ jwkMap["n"] = base64.RawURLEncoding.EncodeToString(key.N.Bytes())
+ eBytes := make([]byte, 0)
+ e := key.E
+ for e > 0 {
+ eBytes = append([]byte{byte(e & 0xff)}, eBytes...)
+ e >>= 8
+ }
+ jwkMap["e"] = base64.RawURLEncoding.EncodeToString(eBytes)
+ default:
+ return nil, fmt.Errorf("unsupported public key type")
+ }
+
+ return jwkMap, nil
+}
+
+func HashToken(token string) string {
+ h := sha256.Sum256([]byte(token))
+ return base64.StdEncoding.EncodeToString(h[:])
+}
diff --git a/signer/internal/cert/ssh_key_test.go b/signer/internal/cert/ssh_key_test.go
new file mode 100644
index 000000000..2a476b484
--- /dev/null
+++ b/signer/internal/cert/ssh_key_test.go
@@ -0,0 +1,306 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cert
+
+import (
+ "crypto/ecdsa"
+ "crypto/ed25519"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/pem"
+ "strings"
+ "testing"
+
+ "golang.org/x/crypto/ssh"
+)
+
+func TestParseSSHPublicKey_Invalid(t *testing.T) {
+ _, err := ParseSSHPublicKey("not-a-valid-ssh-key")
+ if err == nil {
+ t.Error("expected error for invalid SSH key")
+ }
+}
+
+func TestParseSSHPublicKey_Empty(t *testing.T) {
+ _, err := ParseSSHPublicKey("")
+ if err == nil {
+ t.Error("expected error for empty SSH key")
+ }
+}
+
+func TestParseSSHPublicKey_ValidEd25519(t *testing.T) {
+ pub, _, _ := ed25519.GenerateKey(rand.Reader)
+ sshPub, _ := ssh.NewPublicKey(pub)
+ authorizedKey := ssh.MarshalAuthorizedKey(sshPub)
+
+ parsed, err := ParseSSHPublicKey(string(authorizedKey))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if parsed.Type() != "ssh-ed25519" {
+ t.Errorf("expected ssh-ed25519, got %s", parsed.Type())
+ }
+}
+
+func TestParseSSHPublicKey_ValidRSA(t *testing.T) {
+ rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+ sshPub, _ := ssh.NewPublicKey(&rsaKey.PublicKey)
+ authorizedKey := ssh.MarshalAuthorizedKey(sshPub)
+
+ parsed, err := ParseSSHPublicKey(string(authorizedKey))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if parsed.Type() != "ssh-rsa" {
+ t.Errorf("expected ssh-rsa, got %s", parsed.Type())
+ }
+}
+
+func TestParseSSHPublicKey_ValidECDSA(t *testing.T) {
+ ecKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ sshPub, _ := ssh.NewPublicKey(&ecKey.PublicKey)
+ authorizedKey := ssh.MarshalAuthorizedKey(sshPub)
+
+ parsed, err := ParseSSHPublicKey(string(authorizedKey))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if parsed.Type() != "ecdsa-sha2-nistp256" {
+ t.Errorf("expected ecdsa-sha2-nistp256, got %s", parsed.Type())
+ }
+}
+
+func TestNormalizeKeyType(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"ssh-ed25519", "ed25519"},
+ {"ssh-rsa", "rsa"},
+ {"ecdsa-sha2-nistp256", "ecdsa"},
+ {"ecdsa-sha2-nistp384", "ecdsa"},
+ {"unknown", "unknown"},
+ }
+ for _, tt := range tests {
+ got := NormalizeKeyType(tt.input)
+ if got != tt.expected {
+ t.Errorf("NormalizeKeyType(%s): expected %s, got %s", tt.input, tt.expected, got)
+ }
+ }
+}
+
+func TestHashToken(t *testing.T) {
+ hash1 := HashToken("eyJhbGciOiJSUzI1NiIs")
+ hash2 := HashToken("eyJhbGciOiJSUzI1NiIs")
+
+ // Consistent
+ if hash1 != hash2 {
+ t.Error("hash should be consistent for the same input")
+ }
+
+ // Verify it is SHA-256 base64
+ decoded, err := base64.StdEncoding.DecodeString(hash1)
+ if err != nil {
+ t.Fatalf("not valid base64: %v", err)
+ }
+ if len(decoded) != sha256.Size {
+ t.Errorf("expected %d bytes, got %d", sha256.Size, len(decoded))
+ }
+
+ // Different tokens produce different hashes
+ hash3 := HashToken("different-token")
+ if hash1 == hash3 {
+ t.Error("different tokens should produce different hashes")
+ }
+}
+
+func TestSSHPublicKeyFingerprint(t *testing.T) {
+ pub, _, _ := ed25519.GenerateKey(rand.Reader)
+ sshPub, _ := ssh.NewPublicKey(pub)
+ fp := SSHPublicKeyFingerprint(sshPub)
+ if fp == "" {
+ t.Error("expected non-empty fingerprint")
+ }
+ if len(fp) < 7 || fp[:7] != "SHA256:" {
+ t.Errorf("expected SHA256: prefix, got %s", fp)
+ }
+}
+
+func TestParseCAPrivateKey_Ed25519(t *testing.T) {
+ _, priv, _ := ed25519.GenerateKey(rand.Reader)
+ privBytes, _ := x509.MarshalPKCS8PrivateKey(priv)
+ privPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
+
+ signer, sshSigner, err := ParseCAPrivateKey(privPEM)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if signer == nil || sshSigner == nil {
+ t.Fatal("expected non-nil signers")
+ }
+}
+
+func TestParseCAPrivateKey_RSA(t *testing.T) {
+ rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+ privBytes, _ := x509.MarshalPKCS8PrivateKey(rsaKey)
+ privPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
+
+ _, sshSigner, err := ParseCAPrivateKey(privPEM)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if sshSigner == nil {
+ t.Fatal("expected non-nil SSH signer")
+ }
+}
+
+func TestParseCAPrivateKey_ECDSA(t *testing.T) {
+ ecKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ privBytes, _ := x509.MarshalPKCS8PrivateKey(ecKey)
+ privPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
+
+ _, sshSigner, err := ParseCAPrivateKey(privPEM)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if sshSigner == nil {
+ t.Fatal("expected non-nil SSH signer")
+ }
+}
+
+func TestParseCAPrivateKey_InvalidPEM(t *testing.T) {
+ _, _, err := ParseCAPrivateKey([]byte("not a PEM"))
+ if err == nil {
+ t.Error("expected error for invalid PEM")
+ }
+}
+
+func TestPublicKeyToJWK_Ed25519(t *testing.T) {
+ pub, _, _ := ed25519.GenerateKey(rand.Reader)
+ pubBytes, _ := x509.MarshalPKIXPublicKey(pub)
+ pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
+
+ jwkMap, err := PublicKeyToJWK(pubPEM)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if jwkMap["kty"] != "OKP" {
+ t.Errorf("expected kty OKP, got %v", jwkMap["kty"])
+ }
+ if jwkMap["crv"] != "Ed25519" {
+ t.Errorf("expected crv Ed25519, got %v", jwkMap["crv"])
+ }
+ if jwkMap["x"] == nil || jwkMap["x"] == "" {
+ t.Error("expected non-empty x")
+ }
+ if jwkMap["kid"] == nil || jwkMap["kid"] == "" {
+ t.Error("expected non-empty kid")
+ }
+}
+
+func TestCAFingerprint(t *testing.T) {
+ pub, _, _ := ed25519.GenerateKey(rand.Reader)
+ pubBytes, _ := x509.MarshalPKIXPublicKey(pub)
+ pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
+
+ fp, err := CAFingerprint(pubPEM)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if fp == "" || fp[:7] != "SHA256:" {
+ t.Errorf("expected SHA256: fingerprint, got %s", fp)
+ }
+}
+
+func TestPEMToOpenSSH_Ed25519(t *testing.T) {
+ pub, _, _ := ed25519.GenerateKey(rand.Reader)
+ pubBytes, _ := x509.MarshalPKIXPublicKey(pub)
+ pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
+
+ openssh, err := PEMToOpenSSH(pubPEM)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if !strings.HasPrefix(openssh, "ssh-ed25519 ") {
+ t.Errorf("expected ssh-ed25519 prefix, got %q", openssh)
+ }
+
+ // Should be parseable back
+ parsed, err := ParseSSHPublicKey(openssh)
+ if err != nil {
+ t.Fatalf("failed to re-parse OpenSSH key: %v", err)
+ }
+ if parsed.Type() != "ssh-ed25519" {
+ t.Errorf("expected ssh-ed25519, got %s", parsed.Type())
+ }
+}
+
+func TestPEMToOpenSSH_RSA(t *testing.T) {
+ rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+ pubBytes, _ := x509.MarshalPKIXPublicKey(&rsaKey.PublicKey)
+ pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
+
+ openssh, err := PEMToOpenSSH(pubPEM)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if !strings.HasPrefix(openssh, "ssh-rsa ") {
+ t.Errorf("expected ssh-rsa prefix, got %q", openssh)
+ }
+}
+
+func TestPEMToOpenSSH_ECDSA(t *testing.T) {
+ ecKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ pubBytes, _ := x509.MarshalPKIXPublicKey(&ecKey.PublicKey)
+ pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
+
+ openssh, err := PEMToOpenSSH(pubPEM)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if !strings.HasPrefix(openssh, "ecdsa-sha2-nistp256 ") {
+ t.Errorf("expected ecdsa-sha2-nistp256 prefix, got %q", openssh)
+ }
+}
+
+func TestPEMToOpenSSH_InvalidPEM(t *testing.T) {
+ _, err := PEMToOpenSSH([]byte("not valid PEM"))
+ if err == nil {
+ t.Error("expected error for invalid PEM")
+ }
+}
+
+func TestPEMToOpenSSH_NoTrailingNewline(t *testing.T) {
+ pub, _, _ := ed25519.GenerateKey(rand.Reader)
+ pubBytes, _ := x509.MarshalPKIXPublicKey(pub)
+ pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
+
+ openssh, err := PEMToOpenSSH(pubPEM)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if strings.HasSuffix(openssh, "\n") {
+ t.Error("PEMToOpenSSH should not include trailing newline")
+ }
+}
diff --git a/signer/internal/config/config.go b/signer/internal/config/config.go
new file mode 100644
index 000000000..87736d22e
--- /dev/null
+++ b/signer/internal/config/config.go
@@ -0,0 +1,262 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package config handles YAML configuration loading with environment variable overrides.
+package config
+
+import (
+ "os"
+ "strconv"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+type Config struct {
+ Server ServerConfig `yaml:"server"`
+ Database DatabaseConfig `yaml:"database"`
+ Vault VaultConfig `yaml:"vault"`
+ Signer SignerConfig `yaml:"signer"`
+ DevMode DevModeConfig `yaml:"dev_mode"`
+ Logging LoggingConfig `yaml:"logging"`
+ Metrics MetricsConfig `yaml:"metrics"`
+ CORS CORSConfig `yaml:"cors"`
+}
+
+// DevModeConfig disables OIDC token validation and returns a default identity
+// for all requests when enabled.
+type DevModeConfig struct {
+ Enabled bool `yaml:"enabled"`
+ DefaultEmail string `yaml:"default_email"`
+}
+
+type CORSConfig struct {
+ Enabled bool `yaml:"enabled"`
+ AllowedOrigins []string `yaml:"allowed_origins"`
+}
+
+type ServerConfig struct {
+ Port int `yaml:"port"`
+ ReadTimeoutSeconds int `yaml:"read_timeout_seconds"`
+ WriteTimeoutSeconds int `yaml:"write_timeout_seconds"`
+ ShutdownTimeoutSeconds int `yaml:"shutdown_timeout_seconds"`
+}
+
+type DatabaseConfig struct {
+ Host string `yaml:"host"`
+ Port int `yaml:"port"`
+ Name string `yaml:"name"`
+ Username string `yaml:"username"`
+ Password string `yaml:"password"`
+ MaxOpenConns int `yaml:"max_open_conns"`
+ MaxIdleConns int `yaml:"max_idle_conns"`
+ ConnMaxLifetimeSeconds int `yaml:"conn_max_lifetime_seconds"`
+}
+
+type VaultConfig struct {
+ Address string `yaml:"address"`
+ Token string `yaml:"token"`
+ MountPath string `yaml:"mount_path"`
+ TimeoutSeconds int `yaml:"timeout_seconds"`
+}
+
+type SignerConfig struct {
+ CA CAConfig `yaml:"ca"`
+ Policy PolicyConfig `yaml:"policy"`
+ Auth AuthConfig `yaml:"auth"`
+ Validation ValidationConfig `yaml:"validation"`
+}
+
+type CAConfig struct {
+ Rotation RotationConfig `yaml:"rotation"`
+}
+
+type RotationConfig struct {
+ PeriodHours int `yaml:"period_hours"`
+ OverlapHours int `yaml:"overlap_hours"`
+}
+
+type PolicyConfig struct {
+ Defaults PolicyDefaults `yaml:"defaults"`
+}
+
+type PolicyDefaults struct {
+ MaxTTLSeconds int `yaml:"max_ttl_seconds"`
+ AllowedKeyTypes []string `yaml:"allowed_key_types"`
+}
+
+type AuthConfig struct {
+ AllowedIssuers []string `yaml:"allowed_issuers"`
+ OIDC OIDCConfig `yaml:"oidc"`
+}
+
+type OIDCConfig struct {
+ JWKSCacheTTLSeconds int `yaml:"jwks_cache_ttl_seconds"`
+ JWKSMaxProviders int `yaml:"jwks_max_providers"`
+ TimeoutSeconds int `yaml:"timeout_seconds"`
+}
+
+type ValidationConfig struct {
+ PrincipalValidator string `yaml:"principal_validator"`
+ COmanage COmanageConfig `yaml:"comanage"`
+}
+
+type COmanageConfig struct {
+ RegistryURL string `yaml:"registry_url"`
+ APIPath string `yaml:"api_path"`
+ TimeoutSeconds int `yaml:"timeout_seconds"`
+ VerifySSL bool `yaml:"verify_ssl"`
+}
+
+type LoggingConfig struct {
+ Level string `yaml:"level"`
+ Format string `yaml:"format"`
+ AuditLevel string `yaml:"audit_level"`
+}
+
+type MetricsConfig struct {
+ Enabled bool `yaml:"enabled"`
+ Path string `yaml:"path"`
+}
+
+func DefaultConfig() *Config {
+ return &Config{
+ Server: ServerConfig{
+ Port: 8084,
+ ReadTimeoutSeconds: 30,
+ WriteTimeoutSeconds: 30,
+ ShutdownTimeoutSeconds: 30,
+ },
+ Database: DatabaseConfig{
+ Host: "localhost",
+ Port: 3306,
+ Name: "custos_signer",
+ Username: "admin",
+ Password: "admin",
+ MaxOpenConns: 25,
+ MaxIdleConns: 5,
+ ConnMaxLifetimeSeconds: 300,
+ },
+ Vault: VaultConfig{
+ Address: "http://localhost:8200",
+ Token: "",
+ MountPath: "ssh-ca",
+ TimeoutSeconds: 10,
+ },
+ Signer: SignerConfig{
+ CA: CAConfig{
+ Rotation: RotationConfig{
+ PeriodHours: 2160,
+ OverlapHours: 2,
+ },
+ },
+ Policy: PolicyConfig{
+ Defaults: PolicyDefaults{
+ MaxTTLSeconds: 86400,
+ AllowedKeyTypes: []string{"ed25519", "rsa", "ecdsa"},
+ },
+ },
+ Auth: AuthConfig{
+ AllowedIssuers: []string{},
+ OIDC: OIDCConfig{
+ JWKSCacheTTLSeconds: 300,
+ JWKSMaxProviders: 10,
+ TimeoutSeconds: 10,
+ },
+ },
+ Validation: ValidationConfig{
+ PrincipalValidator: "noop",
+ COmanage: COmanageConfig{
+ APIPath: "/registry/co_people.json",
+ TimeoutSeconds: 10,
+ VerifySSL: true,
+ },
+ },
+ },
+ Logging: LoggingConfig{
+ Level: "info",
+ Format: "json",
+ AuditLevel: "info",
+ },
+ Metrics: MetricsConfig{
+ Enabled: true,
+ Path: "/metrics",
+ },
+ }
+}
+
+// Load reads configuration from a YAML file, then applies environment variable overrides.
+func Load(path string) (*Config, error) {
+ cfg := DefaultConfig()
+
+ if path != "" {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ if err := yaml.Unmarshal(data, cfg); err != nil {
+ return nil, err
+ }
+ }
+
+ applyEnvOverrides(cfg)
+ return cfg, nil
+}
+
+func applyEnvOverrides(cfg *Config) {
+ if v := os.Getenv("DB_HOST"); v != "" {
+ cfg.Database.Host = v
+ }
+ if v := os.Getenv("DB_PORT"); v != "" {
+ if p, err := strconv.Atoi(v); err == nil {
+ cfg.Database.Port = p
+ }
+ }
+ if v := os.Getenv("DB_NAME"); v != "" {
+ cfg.Database.Name = v
+ }
+ if v := os.Getenv("DB_USERNAME"); v != "" {
+ cfg.Database.Username = v
+ }
+ if v := os.Getenv("DB_PASSWORD"); v != "" {
+ cfg.Database.Password = v
+ }
+ if v := os.Getenv("VAULT_ADDRESS"); v != "" {
+ cfg.Vault.Address = v
+ }
+ if v := os.Getenv("VAULT_TOKEN"); v != "" {
+ cfg.Vault.Token = v
+ }
+ if v := os.Getenv("DEV_MODE"); v != "" {
+ cfg.DevMode.Enabled = strings.EqualFold(v, "true")
+ }
+ if v := os.Getenv("DEV_DEFAULT_EMAIL"); v != "" {
+ cfg.DevMode.DefaultEmail = v
+ }
+ if v := os.Getenv("ALLOWED_ISSUERS"); v != "" {
+ issuers := strings.Split(v, ",")
+ trimmed := make([]string, 0, len(issuers))
+ for _, issuer := range issuers {
+ s := strings.TrimSpace(issuer)
+ if s != "" {
+ trimmed = append(trimmed, s)
+ }
+ }
+ cfg.Signer.Auth.AllowedIssuers = trimmed
+ }
+ if v := os.Getenv("LOG_LEVEL"); v != "" {
+ cfg.Logging.Level = v
+ }
+}
diff --git a/signer/internal/config/config_test.go b/signer/internal/config/config_test.go
new file mode 100644
index 000000000..f7a3e35fa
--- /dev/null
+++ b/signer/internal/config/config_test.go
@@ -0,0 +1,122 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestLoad_DefaultConfig(t *testing.T) {
+ cfg, err := Load("")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if cfg.Server.Port != 8084 {
+ t.Errorf("expected port 8084, got %d", cfg.Server.Port)
+ }
+ if cfg.Database.Host != "localhost" {
+ t.Errorf("expected host localhost, got %s", cfg.Database.Host)
+ }
+ if cfg.Signer.Validation.PrincipalValidator != "noop" {
+ t.Errorf("expected noop validator, got %s", cfg.Signer.Validation.PrincipalValidator)
+ }
+}
+
+func TestLoad_YAMLFile(t *testing.T) {
+ yaml := `
+server:
+ port: 9090
+database:
+ host: dbhost
+ port: 3307
+ password: file_password
+`
+ tmpDir := t.TempDir()
+ path := filepath.Join(tmpDir, "config.yaml")
+ if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg, err := Load(path)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if cfg.Server.Port != 9090 {
+ t.Errorf("expected port 9090, got %d", cfg.Server.Port)
+ }
+ if cfg.Database.Host != "dbhost" {
+ t.Errorf("expected host dbhost, got %s", cfg.Database.Host)
+ }
+ if cfg.Database.Password != "file_password" {
+ t.Errorf("expected password file_password, got %s", cfg.Database.Password)
+ }
+}
+
+func TestLoad_EnvOverridesYAML(t *testing.T) {
+ yaml := `
+database:
+ password: file_password
+ host: filehost
+`
+ tmpDir := t.TempDir()
+ path := filepath.Join(tmpDir, "config.yaml")
+ if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Setenv("DB_PASSWORD", "env_password")
+ t.Setenv("DB_HOST", "envhost")
+ t.Setenv("VAULT_ADDRESS", "http://vault:8200")
+ t.Setenv("DEV_MODE", "true")
+ t.Setenv("DEV_DEFAULT_EMAIL", "test@dev.local")
+ t.Setenv("ALLOWED_ISSUERS", "https://a.com, https://b.com")
+ t.Setenv("LOG_LEVEL", "debug")
+
+ cfg, err := Load(path)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if cfg.Database.Password != "env_password" {
+ t.Errorf("expected env_password, got %s", cfg.Database.Password)
+ }
+ if cfg.Database.Host != "envhost" {
+ t.Errorf("expected envhost, got %s", cfg.Database.Host)
+ }
+ if cfg.Vault.Address != "http://vault:8200" {
+ t.Errorf("expected vault address override, got %s", cfg.Vault.Address)
+ }
+ if !cfg.DevMode.Enabled {
+ t.Error("expected dev mode enabled via env")
+ }
+ if cfg.DevMode.DefaultEmail != "test@dev.local" {
+ t.Errorf("expected default email 'test@dev.local', got %s", cfg.DevMode.DefaultEmail)
+ }
+ if len(cfg.Signer.Auth.AllowedIssuers) != 2 {
+ t.Errorf("expected 2 issuers, got %d", len(cfg.Signer.Auth.AllowedIssuers))
+ }
+ if cfg.Logging.Level != "debug" {
+ t.Errorf("expected debug log level, got %s", cfg.Logging.Level)
+ }
+}
+
+func TestLoad_FileNotFound(t *testing.T) {
+ _, err := Load("/nonexistent/config.yaml")
+ if err == nil {
+ t.Error("expected error for missing config file")
+ }
+}
diff --git a/signer/internal/handler/admin.go b/signer/internal/handler/admin.go
new file mode 100644
index 000000000..63eca1a88
--- /dev/null
+++ b/signer/internal/handler/admin.go
@@ -0,0 +1,88 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "time"
+
+ "github.com/apache/airavata-custos/signer/internal/cert"
+ "github.com/apache/airavata-custos/signer/internal/httputil"
+ vaultpkg "github.com/apache/airavata-custos/signer/internal/vault"
+)
+
+type RotateCAResponse struct {
+ Success bool `json:"success"`
+ CurrentCAFingerprint string `json:"current_ca_fingerprint"`
+ NextCAFingerprint string `json:"next_ca_fingerprint"`
+ RotationTimestamp int64 `json:"rotation_timestamp"`
+}
+
+type AdminHandler struct {
+ vaultClient *vaultpkg.Client
+ logger *slog.Logger
+}
+
+func NewAdminHandler(vaultClient *vaultpkg.Client, logger *slog.Logger) *AdminHandler {
+ return &AdminHandler{
+ vaultClient: vaultClient,
+ logger: logger,
+ }
+}
+
+func (h *AdminHandler) Handle(w http.ResponseWriter, r *http.Request) {
+ clientCfg := httputil.ClientConfigFromContext(r.Context())
+ if clientCfg == nil {
+ writeError(w, http.StatusInternalServerError, "internal_error", "Missing client config")
+ return
+ }
+
+ tenantID := clientCfg.TenantID
+ clientID := clientCfg.ClientID
+
+ _, _, err := h.vaultClient.RotateCA(r.Context(), tenantID, clientID)
+ if err != nil {
+ h.logger.Error("failed to rotate CA", "error", err, "tenant_id", tenantID, "client_id", clientID)
+ writeError(w, http.StatusInternalServerError, "internal_error", "Failed to rotate CA keys")
+ return
+ }
+
+ currentKey, err := h.vaultClient.GetCurrentCAKey(r.Context(), tenantID, clientID)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read rotated keys")
+ return
+ }
+ currentFP, _ := cert.CAFingerprint(currentKey.PublicKey)
+
+ nextKey, err := h.vaultClient.GetNextCAKey(r.Context(), tenantID, clientID)
+ nextFP := ""
+ if err == nil && nextKey != nil {
+ nextFP, _ = cert.CAFingerprint(nextKey.PublicKey)
+ }
+
+ resp := RotateCAResponse{
+ Success: true,
+ CurrentCAFingerprint: currentFP,
+ NextCAFingerprint: nextFP,
+ RotationTimestamp: time.Now().UTC().Unix(),
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(resp)
+}
diff --git a/signer/internal/handler/ca_public_key.go b/signer/internal/handler/ca_public_key.go
new file mode 100644
index 000000000..760c83d02
--- /dev/null
+++ b/signer/internal/handler/ca_public_key.go
@@ -0,0 +1,93 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "strings"
+
+ "github.com/apache/airavata-custos/signer/internal/cert"
+ "github.com/apache/airavata-custos/signer/internal/httputil"
+ vaultpkg "github.com/apache/airavata-custos/signer/internal/vault"
+)
+
+type CAPublicKeyJSONResponse struct {
+ PublicKey string `json:"public_key"`
+ Fingerprint string `json:"fingerprint"`
+ Algorithm string `json:"algorithm"`
+}
+
+type CAPublicKeyHandler struct {
+ vaultClient *vaultpkg.Client
+ logger *slog.Logger
+}
+
+func NewCAPublicKeyHandler(vaultClient *vaultpkg.Client, logger *slog.Logger) *CAPublicKeyHandler {
+ return &CAPublicKeyHandler{
+ vaultClient: vaultClient,
+ logger: logger,
+ }
+}
+
+func (h *CAPublicKeyHandler) Handle(w http.ResponseWriter, r *http.Request) {
+ clientCfg := httputil.ClientConfigFromContext(r.Context())
+ if clientCfg == nil {
+ writeError(w, http.StatusInternalServerError, "internal_error", "Missing client config")
+ return
+ }
+
+ tenantID := clientCfg.TenantID
+ clientID := clientCfg.ClientID
+
+ currentKey, err := h.vaultClient.GetCurrentCAKey(r.Context(), tenantID, clientID)
+ if err != nil {
+ h.logger.Error("failed to get current CA key", "error", err)
+ writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retrieve CA key")
+ return
+ }
+
+ opensshKey, err := cert.PEMToOpenSSH(currentKey.PublicKey)
+ if err != nil {
+ h.logger.Error("failed to convert CA key to OpenSSH format", "error", err)
+ writeError(w, http.StatusInternalServerError, "internal_error", "Failed to convert CA key")
+ return
+ }
+
+ // If the client accepts JSON, return structured response
+ accept := r.Header.Get("Accept")
+ if strings.Contains(accept, "application/json") {
+ fingerprint, _ := cert.CAFingerprint(currentKey.PublicKey)
+ algorithm := cert.NormalizeKeyType(strings.SplitN(opensshKey, " ", 2)[0])
+
+ resp := CAPublicKeyJSONResponse{
+ PublicKey: opensshKey,
+ Fingerprint: fingerprint,
+ Algorithm: algorithm,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(resp)
+ return
+ }
+
+ // Default: plain text for direct piping to trusted-user-ca-keys
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(opensshKey + "\n"))
+}
diff --git a/signer/internal/handler/ca_public_key_test.go b/signer/internal/handler/ca_public_key_test.go
new file mode 100644
index 000000000..2a41fb03e
--- /dev/null
+++ b/signer/internal/handler/ca_public_key_test.go
@@ -0,0 +1,78 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestCAPublicKeyHandler_MissingClientConfig(t *testing.T) {
+ h := NewCAPublicKeyHandler(nil, nil)
+
+ req := httptest.NewRequest("GET", "/api/v1/ca-public-key", nil)
+ rec := httptest.NewRecorder()
+ h.Handle(rec, req)
+
+ if rec.Code != http.StatusInternalServerError {
+ t.Errorf("expected 500, got %d", rec.Code)
+ }
+
+ var body map[string]string
+ json.NewDecoder(rec.Body).Decode(&body)
+ if body["error"] != "internal_error" {
+ t.Errorf("expected error 'internal_error', got %q", body["error"])
+ }
+ if body["message"] != "Missing client config" {
+ t.Errorf("expected message 'Missing client config', got %q", body["message"])
+ }
+}
+
+func TestCAPublicKeyJSONResponse_Format(t *testing.T) {
+ resp := CAPublicKeyJSONResponse{
+ PublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample",
+ Fingerprint: "SHA256:abcdef123456",
+ Algorithm: "ed25519",
+ }
+
+ data, err := json.Marshal(resp)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+
+ var parsed map[string]interface{}
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+
+ for _, key := range []string{"public_key", "fingerprint", "algorithm"} {
+ if _, ok := parsed[key]; !ok {
+ t.Errorf("missing key %q in JSON output", key)
+ }
+ }
+
+ if parsed["public_key"] != "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample" {
+ t.Errorf("unexpected public_key: %v", parsed["public_key"])
+ }
+ if parsed["fingerprint"] != "SHA256:abcdef123456" {
+ t.Errorf("unexpected fingerprint: %v", parsed["fingerprint"])
+ }
+ if parsed["algorithm"] != "ed25519" {
+ t.Errorf("unexpected algorithm: %v", parsed["algorithm"])
+ }
+}
diff --git a/signer/internal/handler/certificates.go b/signer/internal/handler/certificates.go
new file mode 100644
index 000000000..19f7ef727
--- /dev/null
+++ b/signer/internal/handler/certificates.go
@@ -0,0 +1,179 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+ "database/sql"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/apache/airavata-custos/signer/internal/httputil"
+ "github.com/apache/airavata-custos/signer/internal/store"
+)
+
+type CertificateResponse struct {
+ SerialNumber int64 `json:"serial_number"`
+ KeyID string `json:"key_id"`
+ Principal string `json:"principal"`
+ PublicKeyFingerprint string `json:"public_key_fingerprint"`
+ CAFingerprint string `json:"ca_fingerprint"`
+ ValidAfter int64 `json:"valid_after"`
+ ValidBefore int64 `json:"valid_before"`
+ IssuedAt int64 `json:"issued_at"`
+ SourceIP string `json:"source_ip,omitempty"`
+ Revoked bool `json:"revoked"`
+ RevokedAt *int64 `json:"revoked_at,omitempty"`
+ RevocationReason string `json:"revocation_reason,omitempty"`
+}
+
+type CertificateListResponse struct {
+ Certificates []CertificateResponse `json:"certificates"`
+ Total int `json:"total"`
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+}
+
+type CertificatesHandler struct {
+ db *store.DB
+ logger *slog.Logger
+}
+
+func NewCertificatesHandler(db *store.DB, logger *slog.Logger) *CertificatesHandler {
+ return &CertificatesHandler{
+ db: db,
+ logger: logger,
+ }
+}
+
+func (h *CertificatesHandler) HandleList(w http.ResponseWriter, r *http.Request) {
+ identity := httputil.UserIdentityFromContext(r.Context())
+ if identity == nil {
+ writeError(w, http.StatusUnauthorized, "unauthorized", "Missing user identity")
+ return
+ }
+
+ limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
+ offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
+
+ if limit <= 0 {
+ limit = 20
+ }
+
+ result, err := h.db.ListCertificatesByEmail(r.Context(), identity.Email, limit, offset)
+ if err != nil {
+ h.logger.Error("failed to list certificates", "error", err, "email", identity.Email)
+ writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list certificates")
+ return
+ }
+
+ certs := make([]CertificateResponse, 0, len(result.Certificates))
+ for _, c := range result.Certificates {
+ cert := CertificateResponse{
+ SerialNumber: c.SerialNumber,
+ KeyID: c.KeyID,
+ Principal: c.Principal,
+ PublicKeyFingerprint: c.PublicKeyFingerprint,
+ CAFingerprint: c.CAFingerprint,
+ ValidAfter: c.ValidAfter.Unix(),
+ ValidBefore: c.ValidBefore.Unix(),
+ IssuedAt: c.IssuedAt.Unix(),
+ SourceIP: c.SourceIP,
+ Revoked: c.Revoked,
+ RevocationReason: c.RevocationReason,
+ }
+ if c.RevokedAt != nil {
+ ts := c.RevokedAt.Unix()
+ cert.RevokedAt = &ts
+ }
+ certs = append(certs, cert)
+ }
+
+ resp := CertificateListResponse{
+ Certificates: certs,
+ Total: result.Total,
+ Limit: limit,
+ Offset: offset,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(resp)
+}
+
+// HandleGet expects the serial number as the last URL path segment.
+func (h *CertificatesHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
+ identity := httputil.UserIdentityFromContext(r.Context())
+ if identity == nil {
+ writeError(w, http.StatusUnauthorized, "unauthorized", "Missing user identity")
+ return
+ }
+
+ parts := strings.Split(strings.TrimRight(r.URL.Path, "/"), "/")
+ serialStr := parts[len(parts)-1]
+
+ serial, err := strconv.ParseInt(serialStr, 10, 64)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "invalid_request", "Invalid serial number")
+ return
+ }
+
+ cert, err := h.db.GetCertificateBySerial(r.Context(), serial)
+ if err != nil {
+ if err == sql.ErrNoRows || strings.Contains(err.Error(), "no rows") {
+ writeError(w, http.StatusNotFound, "not_found", "Certificate not found")
+ return
+ }
+ h.logger.Error("failed to get certificate", "error", err, "serial", serial)
+ writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get certificate")
+ return
+ }
+
+ // Authorization: user can only view their own certificates
+ if cert.UserEmail != identity.Email {
+ writeError(w, http.StatusForbidden, "forbidden", "You can only view your own certificates")
+ return
+ }
+
+ resp := toCertificateResponse(cert)
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(resp)
+}
+
+func toCertificateResponse(c *store.CertificateWithStatus) CertificateResponse {
+ resp := CertificateResponse{
+ SerialNumber: c.SerialNumber,
+ KeyID: c.KeyID,
+ Principal: c.Principal,
+ PublicKeyFingerprint: c.PublicKeyFingerprint,
+ CAFingerprint: c.CAFingerprint,
+ ValidAfter: c.ValidAfter.Unix(),
+ ValidBefore: c.ValidBefore.Unix(),
+ IssuedAt: c.IssuedAt.Unix(),
+ SourceIP: c.SourceIP,
+ Revoked: c.Revoked,
+ RevocationReason: c.RevocationReason,
+ }
+ if c.RevokedAt != nil {
+ ts := c.RevokedAt.Unix()
+ resp.RevokedAt = &ts
+ }
+ return resp
+}
diff --git a/signer/internal/handler/certificates_test.go b/signer/internal/handler/certificates_test.go
new file mode 100644
index 000000000..49463f663
--- /dev/null
+++ b/signer/internal/handler/certificates_test.go
@@ -0,0 +1,238 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/apache/airavata-custos/signer/internal/httputil"
+ "github.com/apache/airavata-custos/signer/internal/store"
+)
+
+func TestCertificatesHandler_MissingIdentity(t *testing.T) {
+ h := NewCertificatesHandler(nil, nil)
+
+ req := httptest.NewRequest("GET", "/api/v1/certificates", nil)
+ rec := httptest.NewRecorder()
+ h.HandleList(rec, req)
+
+ if rec.Code != http.StatusUnauthorized {
+ t.Errorf("expected 401, got %d", rec.Code)
+ }
+
+ var body map[string]string
+ json.NewDecoder(rec.Body).Decode(&body)
+ if body["error"] != "unauthorized" {
+ t.Errorf("expected error 'unauthorized', got %q", body["error"])
+ }
+}
+
+func TestCertificatesHandler_WithIdentity_NilDB(t *testing.T) {
+ // When identity is present but DB is nil, the handler should return 500
+ // (this tests the error path -in production DB would never be nil)
+ h := NewCertificatesHandler(nil, nil)
+
+ req := httptest.NewRequest("GET", "/api/v1/certificates", nil)
+ ctx := httputil.WithUserIdentity(req.Context(), &httputil.UserIdentityContext{
+ Issuer: "https://cilogon.org",
+ Subject: "testuser",
+ Principal: "testuser",
+ })
+ req = req.WithContext(ctx)
+
+ rec := httptest.NewRecorder()
+
+ // This will panic due to nil DB -recover and check
+ defer func() {
+ if r := recover(); r != nil {
+ // Expected: nil pointer dereference on DB
+ // This confirms the handler correctly reads the identity from context
+ // before attempting DB access
+ }
+ }()
+ h.HandleList(rec, req)
+}
+
+func TestCertificateListResponse_JSONFormat(t *testing.T) {
+ resp := CertificateListResponse{
+ Certificates: []CertificateResponse{
+ {
+ SerialNumber: 42,
+ KeyID: "user@client-42",
+ Principal: "testuser",
+ PublicKeyFingerprint: "SHA256:abc123",
+ CAFingerprint: "SHA256:def456",
+ ValidAfter: 1709251200,
+ ValidBefore: 1709337600,
+ IssuedAt: 1709251200,
+ Revoked: false,
+ },
+ },
+ Total: 1,
+ Limit: 20,
+ Offset: 0,
+ }
+
+ data, err := json.Marshal(resp)
+ if err != nil {
+ t.Fatalf("failed to marshal response: %v", err)
+ }
+
+ var parsed map[string]interface{}
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ t.Fatalf("failed to unmarshal response: %v", err)
+ }
+
+ if parsed["total"].(float64) != 1 {
+ t.Errorf("expected total=1, got %v", parsed["total"])
+ }
+ if parsed["limit"].(float64) != 20 {
+ t.Errorf("expected limit=20, got %v", parsed["limit"])
+ }
+
+ certs := parsed["certificates"].([]interface{})
+ if len(certs) != 1 {
+ t.Errorf("expected 1 certificate, got %d", len(certs))
+ }
+
+ cert := certs[0].(map[string]interface{})
+ if cert["serial_number"].(float64) != 42 {
+ t.Errorf("expected serial_number=42, got %v", cert["serial_number"])
+ }
+ if cert["revoked"].(bool) != false {
+ t.Errorf("expected revoked=false, got %v", cert["revoked"])
+ }
+}
+
+func TestCertificateDetailHandler_MissingIdentity(t *testing.T) {
+ h := NewCertificatesHandler(nil, nil)
+
+ req := httptest.NewRequest("GET", "/api/v1/certificates/42", nil)
+ rec := httptest.NewRecorder()
+ h.HandleGet(rec, req)
+
+ if rec.Code != http.StatusUnauthorized {
+ t.Errorf("expected 401, got %d", rec.Code)
+ }
+}
+
+func TestCertificateDetailHandler_InvalidSerial(t *testing.T) {
+ h := NewCertificatesHandler(nil, nil)
+
+ req := httptest.NewRequest("GET", "/api/v1/certificates/abc", nil)
+ ctx := httputil.WithUserIdentity(req.Context(), &httputil.UserIdentityContext{
+ Subject: "testuser",
+ })
+ req = req.WithContext(ctx)
+ rec := httptest.NewRecorder()
+ h.HandleGet(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Errorf("expected 400, got %d", rec.Code)
+ }
+
+ var body map[string]string
+ json.NewDecoder(rec.Body).Decode(&body)
+ if body["error"] != "invalid_request" {
+ t.Errorf("expected error 'invalid_request', got %q", body["error"])
+ }
+}
+
+func TestToCertificateResponse(t *testing.T) {
+ now := int64(1709251200)
+ revokedAt := int64(1709337600)
+
+ tests := []struct {
+ name string
+ revoked bool
+ wantRA bool
+ }{
+ {"not revoked", false, false},
+ {"revoked", true, true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &store.CertificateWithStatus{
+ SerialNumber: 42,
+ KeyID: "user@client-42",
+ Principal: "testuser",
+ PublicKeyFingerprint: "SHA256:abc",
+ CAFingerprint: "SHA256:def",
+ ValidAfter: time.Unix(now, 0),
+ ValidBefore: time.Unix(now+86400, 0),
+ IssuedAt: time.Unix(now, 0),
+ Revoked: tt.revoked,
+ }
+ if tt.revoked {
+ ra := time.Unix(revokedAt, 0)
+ c.RevokedAt = &ra
+ c.RevocationReason = "compromised"
+ }
+
+ resp := toCertificateResponse(c)
+ if resp.SerialNumber != 42 {
+ t.Errorf("serial: got %d, want 42", resp.SerialNumber)
+ }
+ if resp.Revoked != tt.revoked {
+ t.Errorf("revoked: got %v, want %v", resp.Revoked, tt.revoked)
+ }
+ if tt.wantRA && resp.RevokedAt == nil {
+ t.Error("expected non-nil RevokedAt")
+ }
+ if !tt.wantRA && resp.RevokedAt != nil {
+ t.Error("expected nil RevokedAt")
+ }
+ })
+ }
+}
+
+func TestUserIdentityContext_RoundTrip(t *testing.T) {
+ req := httptest.NewRequest("GET", "/", nil)
+
+ // Before setting identity
+ if id := httputil.UserIdentityFromContext(req.Context()); id != nil {
+ t.Error("expected nil identity from empty context")
+ }
+
+ // Set identity
+ identity := &httputil.UserIdentityContext{
+ Issuer: "https://cilogon.org",
+ Subject: "http://cilogon.org/serverA/users/12345",
+ Principal: "jdoe",
+ }
+ ctx := httputil.WithUserIdentity(req.Context(), identity)
+ req = req.WithContext(ctx)
+
+ // Retrieve
+ got := httputil.UserIdentityFromContext(req.Context())
+ if got == nil {
+ t.Fatal("expected identity, got nil")
+ }
+ if got.Subject != identity.Subject {
+ t.Errorf("subject: expected %q, got %q", identity.Subject, got.Subject)
+ }
+ if got.Issuer != identity.Issuer {
+ t.Errorf("issuer: expected %q, got %q", identity.Issuer, got.Issuer)
+ }
+ if got.Principal != identity.Principal {
+ t.Errorf("principal: expected %q, got %q", identity.Principal, got.Principal)
+ }
+}
diff --git a/signer/internal/handler/common.go b/signer/internal/handler/common.go
new file mode 100644
index 000000000..e8ac579f5
--- /dev/null
+++ b/signer/internal/handler/common.go
@@ -0,0 +1,26 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+ "net/http"
+
+ "github.com/apache/airavata-custos/signer/internal/httputil"
+)
+
+func writeError(w http.ResponseWriter, status int, code, message string) {
+ httputil.WriteJSONError(w, status, code, message)
+}
diff --git a/signer/internal/handler/health.go b/signer/internal/handler/health.go
new file mode 100644
index 000000000..5adb7e190
--- /dev/null
+++ b/signer/internal/handler/health.go
@@ -0,0 +1,119 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "time"
+
+ "github.com/apache/airavata-custos/signer/internal/store"
+ vaultpkg "github.com/apache/airavata-custos/signer/internal/vault"
+)
+
+type HealthResponse struct {
+ Status string `json:"status"`
+ Checks map[string]*HealthCheckResult `json:"checks"`
+}
+
+type HealthCheckResult struct {
+ Status string `json:"status"`
+ LatencyMs int64 `json:"latency_ms,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type HealthHandler struct {
+ db *store.DB
+ vaultClient *vaultpkg.Client
+}
+
+func NewHealthHandler(db *store.DB, vaultClient *vaultpkg.Client) *HealthHandler {
+ return &HealthHandler{
+ db: db,
+ vaultClient: vaultClient,
+ }
+}
+
+func (h *HealthHandler) Handle(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ checks := make(map[string]*HealthCheckResult)
+ healthy := true
+
+ dbResult := h.checkDatabase(ctx)
+ checks["database"] = dbResult
+ if dbResult.Status != "up" {
+ healthy = false
+ }
+
+ vaultResult := h.checkVault(ctx)
+ checks["vault"] = vaultResult
+ if vaultResult.Status != "up" {
+ healthy = false
+ }
+
+ status := "healthy"
+ httpStatus := http.StatusOK
+ if !healthy {
+ status = "unhealthy"
+ httpStatus = http.StatusServiceUnavailable
+ }
+
+ resp := HealthResponse{
+ Status: status,
+ Checks: checks,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(httpStatus)
+ json.NewEncoder(w).Encode(resp)
+}
+
+func (h *HealthHandler) checkDatabase(ctx context.Context) *HealthCheckResult {
+ start := time.Now()
+ err := h.db.Ping()
+ latency := time.Since(start).Milliseconds()
+
+ if err != nil {
+ return &HealthCheckResult{
+ Status: "down",
+ LatencyMs: latency,
+ Error: "database connection failed",
+ }
+ }
+ return &HealthCheckResult{
+ Status: "up",
+ LatencyMs: latency,
+ }
+}
+
+func (h *HealthHandler) checkVault(ctx context.Context) *HealthCheckResult {
+ start := time.Now()
+ err := h.vaultClient.Healthy(ctx)
+ latency := time.Since(start).Milliseconds()
+
+ if err != nil {
+ return &HealthCheckResult{
+ Status: "down",
+ LatencyMs: latency,
+ Error: "vault connection failed",
+ }
+ }
+ return &HealthCheckResult{
+ Status: "up",
+ LatencyMs: latency,
+ }
+}
diff --git a/signer/internal/handler/jwks.go b/signer/internal/handler/jwks.go
new file mode 100644
index 000000000..46fc15371
--- /dev/null
+++ b/signer/internal/handler/jwks.go
@@ -0,0 +1,102 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "time"
+
+ "github.com/apache/airavata-custos/signer/internal/cert"
+ "github.com/apache/airavata-custos/signer/internal/httputil"
+ vaultpkg "github.com/apache/airavata-custos/signer/internal/vault"
+)
+
+type JWKSResponse struct {
+ CurrentKey interface{} `json:"current_key"`
+ NextKey interface{} `json:"next_key"`
+ CurrentFingerprint string `json:"current_fingerprint"`
+ NextFingerprint string `json:"next_fingerprint"`
+ RotationScheduledAt int64 `json:"rotation_scheduled_at"`
+}
+
+type JWKSHandler struct {
+ vaultClient *vaultpkg.Client
+ logger *slog.Logger
+}
+
+func NewJWKSHandler(vaultClient *vaultpkg.Client, logger *slog.Logger) *JWKSHandler {
+ return &JWKSHandler{
+ vaultClient: vaultClient,
+ logger: logger,
+ }
+}
+
+func (h *JWKSHandler) Handle(w http.ResponseWriter, r *http.Request) {
+ clientCfg := httputil.ClientConfigFromContext(r.Context())
+ if clientCfg == nil {
+ writeError(w, http.StatusInternalServerError, "internal_error", "Missing client config")
+ return
+ }
+
+ tenantID := clientCfg.TenantID
+ clientID := clientCfg.ClientID
+
+ currentKey, err := h.vaultClient.GetCurrentCAKey(r.Context(), tenantID, clientID)
+ if err != nil {
+ h.logger.Error("failed to get current CA key", "error", err)
+ writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retrieve CA keys")
+ return
+ }
+
+ currentJWK, err := cert.PublicKeyToJWK(currentKey.PublicKey)
+ if err != nil {
+ h.logger.Error("failed to convert current key to JWK", "error", err)
+ writeError(w, http.StatusInternalServerError, "internal_error", "Failed to convert CA key to JWK")
+ return
+ }
+
+ currentFP, _ := cert.CAFingerprint(currentKey.PublicKey)
+
+ resp := JWKSResponse{
+ CurrentKey: currentJWK,
+ NextKey: "",
+ CurrentFingerprint: currentFP,
+ NextFingerprint: "",
+ }
+
+ nextKey, err := h.vaultClient.GetNextCAKey(r.Context(), tenantID, clientID)
+ if err == nil && nextKey != nil {
+ nextJWK, err := cert.PublicKeyToJWK(nextKey.PublicKey)
+ if err == nil {
+ resp.NextKey = nextJWK
+ nextFP, _ := cert.CAFingerprint(nextKey.PublicKey)
+ resp.NextFingerprint = nextFP
+ }
+ }
+
+ meta, err := h.vaultClient.GetMetadata(r.Context(), tenantID, clientID)
+ if err == nil && meta != nil && meta.NextRotationAt != "" {
+ if t, err := time.Parse(time.RFC3339, meta.NextRotationAt); err == nil {
+ resp.RotationScheduledAt = t.Unix()
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(resp)
+}
diff --git a/signer/internal/handler/revoke.go b/signer/internal/handler/revoke.go
new file mode 100644
index 000000000..b9b69f6e7
--- /dev/null
+++ b/signer/internal/handler/revoke.go
@@ -0,0 +1,110 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+ "encoding/json"
+ "log/slog"
+ "net/http"
+
+ "github.com/apache/airavata-custos/signer/internal/audit"
+ "github.com/apache/airavata-custos/signer/internal/httputil"
+ "github.com/apache/airavata-custos/signer/internal/metrics"
+)
+
+type RevokeRequest struct {
+ SerialNumber *int64 `json:"serial_number,omitempty"`
+ KeyID *string `json:"key_id,omitempty"`
+ CAFingerprint *string `json:"ca_fingerprint,omitempty"`
+ Reason string `json:"reason"`
+}
+
+type RevokeResponse struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+ RevokedCount int `json:"revoked_count"`
+}
+
+type RevokeHandler struct {
+ auditLogger *audit.Logger
+ logger *slog.Logger
+}
+
+func NewRevokeHandler(auditLogger *audit.Logger, logger *slog.Logger) *RevokeHandler {
+ return &RevokeHandler{
+ auditLogger: auditLogger,
+ logger: logger,
+ }
+}
+
+func (h *RevokeHandler) Handle(w http.ResponseWriter, r *http.Request) {
+ clientCfg := httputil.ClientConfigFromContext(r.Context())
+ if clientCfg == nil {
+ writeError(w, http.StatusInternalServerError, "internal_error", "Missing client config")
+ return
+ }
+
+ tenantID := clientCfg.TenantID
+ clientID := clientCfg.ClientID
+
+ var req RevokeRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ metrics.RevokeRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+ writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body")
+ return
+ }
+
+ if req.SerialNumber == nil && req.KeyID == nil && req.CAFingerprint == nil {
+ metrics.RevokeRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+ writeError(w, http.StatusBadRequest, "invalid_request", "At least one of serial_number, key_id, or ca_fingerprint is required")
+ return
+ }
+
+ if req.Reason == "" {
+ metrics.RevokeRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+ writeError(w, http.StatusBadRequest, "invalid_request", "Missing required field: reason")
+ return
+ }
+
+ entry := &audit.RevocationEntry{
+ TenantID: tenantID,
+ ClientID: clientID,
+ SerialNumber: req.SerialNumber,
+ KeyID: req.KeyID,
+ CAFingerprint: req.CAFingerprint,
+ Reason: req.Reason,
+ RevokedBy: tenantID + ":" + clientID,
+ }
+
+ if err := h.auditLogger.LogRevocation(r.Context(), entry); err != nil {
+ metrics.RevokeRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+ h.logger.Error("failed to record revocation", "error", err)
+ writeError(w, http.StatusInternalServerError, "internal_error", "Failed to record revocation")
+ return
+ }
+
+ metrics.RevokeRequestsTotal.WithLabelValues(tenantID, "success").Inc()
+
+ resp := RevokeResponse{
+ Success: true,
+ Message: "Certificate(s) revoked successfully",
+ RevokedCount: 1,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(resp)
+}
diff --git a/signer/internal/handler/revoke_test.go b/signer/internal/handler/revoke_test.go
new file mode 100644
index 000000000..d8d4e3c3d
--- /dev/null
+++ b/signer/internal/handler/revoke_test.go
@@ -0,0 +1,53 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestRevokeRequest_NoIdentifier(t *testing.T) {
+ req := RevokeRequest{
+ Reason: "no reason",
+ }
+ if req.SerialNumber != nil || req.KeyID != nil || req.CAFingerprint != nil {
+ t.Error("all identifiers should be nil")
+ }
+}
+
+// Validate JSON marshaling
+func TestRevokeResponse_JSON(t *testing.T) {
+ resp := RevokeResponse{
+ Success: true,
+ Message: "Certificate(s) revoked successfully",
+ RevokedCount: 1,
+ }
+ data, err := json.Marshal(resp)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var parsed map[string]interface{}
+ json.Unmarshal(data, &parsed)
+
+ if parsed["success"] != true {
+ t.Error("expected success true")
+ }
+ if parsed["revoked_count"].(float64) != 1 {
+ t.Error("expected revoked_count 1")
+ }
+}
diff --git a/signer/internal/handler/sign.go b/signer/internal/handler/sign.go
new file mode 100644
index 000000000..fed25f3ea
--- /dev/null
+++ b/signer/internal/handler/sign.go
@@ -0,0 +1,298 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/apache/airavata-custos/signer/internal/audit"
+ "github.com/apache/airavata-custos/signer/internal/auth"
+ "github.com/apache/airavata-custos/signer/internal/cert"
+ "github.com/apache/airavata-custos/signer/internal/httputil"
+ "github.com/apache/airavata-custos/signer/internal/metrics"
+ "github.com/apache/airavata-custos/signer/internal/policy"
+ "github.com/apache/airavata-custos/signer/internal/validation"
+ vaultpkg "github.com/apache/airavata-custos/signer/internal/vault"
+)
+
+var principalRegex = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
+
+type SignRequest struct {
+ Principal string `json:"principal"`
+ TTLSeconds int `json:"ttl_seconds"`
+ PublicKey string `json:"public_key"`
+ UserAccessToken string `json:"user_access_token"`
+}
+
+type SignResponse struct {
+ Certificate string `json:"certificate"`
+ SerialNumber int64 `json:"serial_number"`
+ ValidAfter int64 `json:"valid_after"`
+ ValidBefore int64 `json:"valid_before"`
+ CAFingerprint string `json:"ca_fingerprint"`
+ TargetHost string `json:"target_host"`
+ TargetPort int `json:"target_port"`
+ TargetUsername string `json:"target_username"`
+}
+
+type SignHandler struct {
+ oidcValidator *auth.OIDCValidator
+ policyEnforcer *policy.Enforcer
+ principalValidator validation.PrincipalValidator
+ vaultClient *vaultpkg.Client
+ auditLogger *audit.Logger
+ logger *slog.Logger
+}
+
+func NewSignHandler(
+ oidcValidator *auth.OIDCValidator,
+ policyEnforcer *policy.Enforcer,
+ principalValidator validation.PrincipalValidator,
+ vaultClient *vaultpkg.Client,
+ auditLogger *audit.Logger,
+ logger *slog.Logger,
+) *SignHandler {
+ return &SignHandler{
+ oidcValidator: oidcValidator,
+ policyEnforcer: policyEnforcer,
+ principalValidator: principalValidator,
+ vaultClient: vaultClient,
+ auditLogger: auditLogger,
+ logger: logger,
+ }
+}
+
+func (h *SignHandler) Handle(w http.ResponseWriter, r *http.Request) {
+ startTime := time.Now()
+ clientCfg := httputil.ClientConfigFromContext(r.Context())
+ if clientCfg == nil {
+ writeError(w, http.StatusInternalServerError, "internal_error", "Missing client config")
+ return
+ }
+
+ tenantID := clientCfg.TenantID
+ clientID := clientCfg.ClientID
+
+ defer func() {
+ duration := time.Since(startTime).Seconds()
+ metrics.SignDurationSeconds.WithLabelValues(tenantID).Observe(duration)
+ }()
+
+ var req SignRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+ writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body")
+ return
+ }
+
+ if req.Principal == "" {
+ metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+ writeError(w, http.StatusBadRequest, "invalid_request", "Missing required field: principal")
+ return
+ }
+ if !principalRegex.MatchString(req.Principal) {
+ metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+ writeError(w, http.StatusBadRequest, "invalid_request", "Invalid principal format: must match ^[a-z_][a-z0-9_-]{0,31}$")
+ return
+ }
+
+ if req.PublicKey == "" {
+ metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+ writeError(w, http.StatusBadRequest, "invalid_request", "Missing required field: public_key")
+ return
+ }
+ if req.UserAccessToken == "" {
+ metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+ writeError(w, http.StatusBadRequest, "invalid_request", "Missing required field: user_access_token")
+ return
+ }
+
+ sshPubKey, err := cert.ParseSSHPublicKey(req.PublicKey)
+ if err != nil {
+ metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+ writeError(w, http.StatusBadRequest, "invalid_request", "Invalid SSH public key format")
+ return
+ }
+
+ identity, err := h.oidcValidator.ValidateAccessToken(r.Context(), req.UserAccessToken)
+ if err != nil {
+ metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+ if authErr, ok := err.(*auth.AuthError); ok {
+ writeError(w, authErr.Status, authErr.Code, authErr.Message)
+ return
+ }
+ writeError(w, http.StatusInternalServerError, "internal_error", "Token validation error")
+ return
+ }
+
+ sourceIP := httputil.SourceIPFromContext(r.Context())
+ if err := h.policyEnforcer.Enforce(req.TTLSeconds, sshPubKey.Type(), sourceIP, clientCfg); err != nil {
+ metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+ if policyErr, ok := err.(*policy.PolicyError); ok {
+ writeError(w, http.StatusBadRequest, "policy_violation", policyErr.Message)
+ return
+ }
+ writeError(w, http.StatusInternalServerError, "internal_error", "Policy enforcement error")
+ return
+ }
+
+ valResult, err := h.principalValidator.Validate(tenantID, clientID, req.Principal, identity.Subject)
+ if err != nil {
+ metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+ if valErr, ok := err.(*validation.ValidationError); ok {
+ httputil.WriteJSONErrorWithExtra(w, http.StatusForbidden, "principal_denied", valErr.Message, map[string]string{
+ "reason_code": valErr.ReasonCode,
+ })
+ return
+ }
+ writeError(w, http.StatusInternalServerError, "internal_error", "Principal validation error")
+ return
+ }
+
+ validatedPrincipal := valResult.ValidatedPrincipal
+
+ caKeyPair, err := h.vaultClient.GetCurrentCAKey(r.Context(), tenantID, clientID)
+ if err != nil {
+ metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+ metrics.VaultOperationsTotal.WithLabelValues("get_ca_key", "error").Inc()
+ h.logger.Error("failed to get CA key", "error", err, "tenant_id", tenantID, "client_id", clientID)
+ writeError(w, http.StatusServiceUnavailable, "internal_error", "CA key storage unavailable")
+ return
+ }
+ metrics.VaultOperationsTotal.WithLabelValues("get_ca_key", "success").Inc()
+
+ serial, err := h.vaultClient.IncrementSerialCounter(r.Context(), tenantID, clientID)
+ if err != nil {
+ metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+ metrics.VaultOperationsTotal.WithLabelValues("increment_serial", "error").Inc()
+ h.logger.Error("failed to increment serial counter", "error", err, "tenant_id", tenantID, "client_id", clientID)
+ writeError(w, http.StatusInternalServerError, "internal_error", "Failed to allocate serial number")
+ return
+ }
+ metrics.VaultOperationsTotal.WithLabelValues("increment_serial", "success").Inc()
+
+ criticalOpts := policy.GetCriticalOptions(clientCfg)
+
+ signReq := &cert.SignRequest{
+ PublicKey: sshPubKey,
+ CAPrivateKeyPEM: caKeyPair.PrivateKey,
+ Serial: uint64(serial),
+ Principal: validatedPrincipal,
+ ClientID: clientID,
+ TTLSeconds: uint64(req.TTLSeconds),
+ CriticalOptions: criticalOpts,
+ }
+
+ signResult, err := cert.SignCertificate(signReq)
+ if err != nil {
+ metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+ h.logger.Error("failed to sign certificate", "error", err)
+ writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sign certificate")
+ return
+ }
+
+ tokenHash := cert.HashToken(req.UserAccessToken)
+ pubKeyFP := cert.SSHPublicKeyFingerprint(sshPubKey)
+
+ auditEntry := &audit.IssuanceEntry{
+ TenantID: tenantID,
+ ClientID: clientID,
+ SerialNumber: serial,
+ KeyID: signResult.KeyID,
+ Principal: validatedPrincipal,
+ UserEmail: identity.Email,
+ PublicKeyFingerprint: pubKeyFP,
+ CAFingerprint: signResult.CAFingerprint,
+ ValidAfter: time.Unix(int64(signResult.ValidAfter), 0).UTC(),
+ ValidBefore: time.Unix(int64(signResult.ValidBefore), 0).UTC(),
+ SourceIP: sourceIP,
+ UserAccessTokenHash: tokenHash,
+ }
+
+ if err := h.auditLogger.LogIssuance(r.Context(), auditEntry); err != nil {
+ if !isDuplicateKeyError(err) {
+ metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+ h.logger.Error("failed to write audit log", "error", err)
+ writeError(w, http.StatusInternalServerError, "internal_error", "Failed to record issuance")
+ return
+ }
+
+ // Duplicate serial — retry with a fresh serial
+ h.logger.Warn("duplicate serial detected, retrying with fresh serial",
+ "serial", serial, "tenant_id", tenantID, "client_id", clientID)
+
+ retrySerial, err := h.vaultClient.IncrementSerialCounter(r.Context(), tenantID, clientID)
+ if err != nil {
+ metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+ metrics.VaultOperationsTotal.WithLabelValues("increment_serial", "error").Inc()
+ writeError(w, http.StatusInternalServerError, "internal_error", "Failed to allocate serial number")
+ return
+ }
+ metrics.VaultOperationsTotal.WithLabelValues("increment_serial", "success").Inc()
+
+ signReq.Serial = uint64(retrySerial)
+ retryResult, err := cert.SignCertificate(signReq)
+ if err != nil {
+ metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+ h.logger.Error("failed to sign certificate on retry", "error", err)
+ writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sign certificate")
+ return
+ }
+
+ serial = retrySerial
+ signResult = retryResult
+ auditEntry.SerialNumber = retrySerial
+ auditEntry.KeyID = retryResult.KeyID
+ auditEntry.CAFingerprint = retryResult.CAFingerprint
+ auditEntry.ValidAfter = time.Unix(int64(retryResult.ValidAfter), 0).UTC()
+ auditEntry.ValidBefore = time.Unix(int64(retryResult.ValidBefore), 0).UTC()
+
+ if err := h.auditLogger.LogIssuance(r.Context(), auditEntry); err != nil {
+ metrics.SignRequestsTotal.WithLabelValues(tenantID, "error").Inc()
+ h.logger.Error("failed to write audit log on retry", "error", err)
+ writeError(w, http.StatusInternalServerError, "internal_error", "Failed to record issuance")
+ return
+ }
+ }
+
+ metrics.SignRequestsTotal.WithLabelValues(tenantID, "success").Inc()
+
+ resp := SignResponse{
+ Certificate: base64.StdEncoding.EncodeToString(signResult.CertBytes),
+ SerialNumber: serial,
+ ValidAfter: int64(signResult.ValidAfter),
+ ValidBefore: int64(signResult.ValidBefore),
+ CAFingerprint: signResult.CAFingerprint,
+ TargetHost: clientCfg.TargetHost,
+ TargetPort: clientCfg.TargetPort,
+ TargetUsername: validatedPrincipal,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(resp)
+}
+
+// isDuplicateKeyError detects MySQL error 1062 (duplicate key constraint).
+func isDuplicateKeyError(err error) bool {
+ return err != nil && strings.Contains(err.Error(), "Duplicate entry")
+}
diff --git a/signer/internal/handler/sign_test.go b/signer/internal/handler/sign_test.go
new file mode 100644
index 000000000..14b094262
--- /dev/null
+++ b/signer/internal/handler/sign_test.go
@@ -0,0 +1,85 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+ "errors"
+ "regexp"
+ "testing"
+)
+
+func TestPrincipalRegex(t *testing.T) {
+ re := regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
+
+ tests := []struct {
+ name string
+ input string
+ valid bool
+ }{
+ // Valid cases
+ {"simple lowercase", "jdoe", true},
+ {"underscore start", "_user", true},
+ {"with hyphen", "j-doe", true},
+ {"with underscore", "j_doe", true},
+ {"with numbers", "user123", true},
+ {"single char", "a", true},
+ {"32 chars", "abcdefghijklmnopqrstuvwxyz012345", true},
+
+ // Invalid cases
+ {"uppercase", "JDoe", false},
+ {"dot", "j.doe", false},
+ {"at sign", "jdoe@example", false},
+ {"digit start", "1user", false},
+ {"33 chars", "abcdefghijklmnopqrstuvwxyz0123456", false},
+ {"empty", "", false},
+ {"space", "j doe", false},
+ {"special chars", "user$", false},
+ {"John.Doe", "John.Doe", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := re.MatchString(tt.input)
+ if got != tt.valid {
+ t.Errorf("principalRegex.MatchString(%q) = %v, want %v", tt.input, got, tt.valid)
+ }
+ })
+ }
+}
+
+func TestIsDuplicateKeyError(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ want bool
+ }{
+ {"nil error", nil, false},
+ {"generic error", errors.New("connection refused"), false},
+ {"mysql duplicate entry", errors.New("Error 1062 (23000): Duplicate entry '42' for key 'serial_number'"), true},
+ {"wrapped duplicate entry", errors.New("failed to insert: Duplicate entry '99' for key 'PRIMARY'"), true},
+ {"partial match", errors.New("Duplicate entry"), true},
+ {"case sensitive no match", errors.New("duplicate entry"), false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := isDuplicateKeyError(tt.err)
+ if got != tt.want {
+ t.Errorf("isDuplicateKeyError(%v) = %v, want %v", tt.err, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/signer/internal/handler/userinfo.go b/signer/internal/handler/userinfo.go
new file mode 100644
index 000000000..a5c1ca4a1
--- /dev/null
+++ b/signer/internal/handler/userinfo.go
@@ -0,0 +1,55 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/apache/airavata-custos/signer/internal/httputil"
+)
+
+type UserInfoResponse struct {
+ Subject string `json:"subject"`
+ Issuer string `json:"issuer"`
+ Email string `json:"email"`
+ Principal string `json:"principal"`
+}
+
+type UserInfoHandler struct{}
+
+func NewUserInfoHandler() *UserInfoHandler {
+ return &UserInfoHandler{}
+}
+
+func (h *UserInfoHandler) Handle(w http.ResponseWriter, r *http.Request) {
+ identity := httputil.UserIdentityFromContext(r.Context())
+ if identity == nil {
+ writeError(w, http.StatusUnauthorized, "unauthorized", "Missing user identity")
+ return
+ }
+
+ resp := UserInfoResponse{
+ Subject: identity.Subject,
+ Issuer: identity.Issuer,
+ Email: identity.Email,
+ Principal: identity.Principal,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(resp)
+}
diff --git a/signer/internal/handler/userinfo_test.go b/signer/internal/handler/userinfo_test.go
new file mode 100644
index 000000000..9d997d2c1
--- /dev/null
+++ b/signer/internal/handler/userinfo_test.go
@@ -0,0 +1,93 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/apache/airavata-custos/signer/internal/httputil"
+)
+
+func TestUserInfoHandler_MissingIdentity(t *testing.T) {
+ h := NewUserInfoHandler()
+
+ req := httptest.NewRequest("GET", "/api/v1/userinfo", nil)
+ rec := httptest.NewRecorder()
+ h.Handle(rec, req)
+
+ if rec.Code != http.StatusUnauthorized {
+ t.Errorf("expected 401, got %d", rec.Code)
+ }
+}
+
+func TestUserInfoHandler_Success(t *testing.T) {
+ h := NewUserInfoHandler()
+
+ req := httptest.NewRequest("GET", "/api/v1/userinfo", nil)
+ ctx := httputil.WithUserIdentity(req.Context(), &httputil.UserIdentityContext{
+ Issuer: "https://cilogon.org",
+ Subject: "http://cilogon.org/serverA/users/12345",
+ Principal: "jdoe",
+ })
+ req = req.WithContext(ctx)
+
+ rec := httptest.NewRecorder()
+ h.Handle(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Errorf("expected 200, got %d", rec.Code)
+ }
+
+ var resp UserInfoResponse
+ if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if resp.Subject != "http://cilogon.org/serverA/users/12345" {
+ t.Errorf("subject: got %q", resp.Subject)
+ }
+ if resp.Issuer != "https://cilogon.org" {
+ t.Errorf("issuer: got %q", resp.Issuer)
+ }
+ if resp.Principal != "jdoe" {
+ t.Errorf("principal: got %q", resp.Principal)
+ }
+}
+
+func TestUserInfoResponse_JSONFormat(t *testing.T) {
+ resp := UserInfoResponse{
+ Subject: "user123",
+ Issuer: "https://example.com",
+ Principal: "jdoe",
+ }
+
+ data, err := json.Marshal(resp)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+
+ var parsed map[string]interface{}
+ json.Unmarshal(data, &parsed)
+
+ for _, key := range []string{"subject", "issuer", "principal"} {
+ if _, ok := parsed[key]; !ok {
+ t.Errorf("missing key %q in JSON output", key)
+ }
+ }
+}
diff --git a/signer/internal/httputil/context.go b/signer/internal/httputil/context.go
new file mode 100644
index 000000000..77932fc91
--- /dev/null
+++ b/signer/internal/httputil/context.go
@@ -0,0 +1,95 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package httputil provides shared HTTP utilities like context keys and JSON helpers.
+package httputil
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+
+ "github.com/apache/airavata-custos/signer/internal/store"
+)
+
+type contextKey string
+
+const (
+ clientConfigKey contextKey = "clientConfig"
+ sourceIPKey contextKey = "sourceIP"
+ userIdentityKey contextKey = "userIdentity"
+)
+
+type UserIdentityContext struct {
+ Issuer string
+ Subject string
+ Email string
+ Principal string
+}
+
+func WithUserIdentity(ctx context.Context, id *UserIdentityContext) context.Context {
+ return context.WithValue(ctx, userIdentityKey, id)
+}
+
+func UserIdentityFromContext(ctx context.Context) *UserIdentityContext {
+ if v := ctx.Value(userIdentityKey); v != nil {
+ return v.(*UserIdentityContext)
+ }
+ return nil
+}
+
+func WithClientConfig(ctx context.Context, cfg *store.ClientConfig) context.Context {
+ return context.WithValue(ctx, clientConfigKey, cfg)
+}
+
+func ClientConfigFromContext(ctx context.Context) *store.ClientConfig {
+ if v := ctx.Value(clientConfigKey); v != nil {
+ return v.(*store.ClientConfig)
+ }
+ return nil
+}
+
+func WithSourceIP(ctx context.Context, ip string) context.Context {
+ return context.WithValue(ctx, sourceIPKey, ip)
+}
+
+func SourceIPFromContext(ctx context.Context) string {
+ if v := ctx.Value(sourceIPKey); v != nil {
+ return v.(string)
+ }
+ return ""
+}
+
+func WriteJSONError(w http.ResponseWriter, status int, code, message string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(map[string]string{
+ "error": code,
+ "message": message,
+ })
+}
+
+func WriteJSONErrorWithExtra(w http.ResponseWriter, status int, code, message string, extra map[string]string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ resp := map[string]string{
+ "error": code,
+ "message": message,
+ }
+ for k, v := range extra {
+ resp[k] = v
+ }
+ json.NewEncoder(w).Encode(resp)
+}
diff --git a/signer/internal/httputil/context_test.go b/signer/internal/httputil/context_test.go
new file mode 100644
index 000000000..99928c3f8
--- /dev/null
+++ b/signer/internal/httputil/context_test.go
@@ -0,0 +1,92 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package httputil
+
+import (
+ "context"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/apache/airavata-custos/signer/internal/store"
+)
+
+func TestClientConfigContext(t *testing.T) {
+ cfg := &store.ClientConfig{
+ TenantID: "t1",
+ ClientID: "c1",
+ }
+ ctx := WithClientConfig(context.Background(), cfg)
+ got := ClientConfigFromContext(ctx)
+ if got == nil {
+ t.Fatal("expected non-nil config")
+ }
+ if got.TenantID != "t1" || got.ClientID != "c1" {
+ t.Errorf("unexpected config: %+v", got)
+ }
+}
+
+func TestClientConfigContext_Nil(t *testing.T) {
+ got := ClientConfigFromContext(context.Background())
+ if got != nil {
+ t.Error("expected nil for empty context")
+ }
+}
+
+func TestSourceIPContext(t *testing.T) {
+ ctx := WithSourceIP(context.Background(), "10.0.0.1")
+ got := SourceIPFromContext(ctx)
+ if got != "10.0.0.1" {
+ t.Errorf("expected 10.0.0.1, got %s", got)
+ }
+}
+
+func TestSourceIPContext_Empty(t *testing.T) {
+ got := SourceIPFromContext(context.Background())
+ if got != "" {
+ t.Errorf("expected empty, got %s", got)
+ }
+}
+
+func TestWriteJSONError(t *testing.T) {
+ rec := httptest.NewRecorder()
+ WriteJSONError(rec, 401, "unauthorized", "Missing header")
+
+ if rec.Code != 401 {
+ t.Errorf("expected 401, got %d", rec.Code)
+ }
+ if rec.Header().Get("Content-Type") != "application/json" {
+ t.Error("expected application/json content type")
+ }
+ body := rec.Body.String()
+ if body == "" {
+ t.Error("expected non-empty body")
+ }
+}
+
+func TestWriteJSONErrorWithExtra(t *testing.T) {
+ rec := httptest.NewRecorder()
+ WriteJSONErrorWithExtra(rec, 403, "principal_denied", "Not allowed", map[string]string{
+ "reason_code": "COMANAGE_NOT_IMPLEMENTED",
+ })
+
+ if rec.Code != 403 {
+ t.Errorf("expected 403, got %d", rec.Code)
+ }
+ body := rec.Body.String()
+ if body == "" {
+ t.Error("expected non-empty body")
+ }
+}
diff --git a/signer/internal/metrics/metrics.go b/signer/internal/metrics/metrics.go
new file mode 100644
index 000000000..61a4ac6ff
--- /dev/null
+++ b/signer/internal/metrics/metrics.go
@@ -0,0 +1,56 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package metrics defines Prometheus metric collectors.
+package metrics
+
+import (
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promauto"
+)
+
+var (
+ SignRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
+ Name: "signer_sign_requests_total",
+ Help: "Total number of signing requests",
+ }, []string{"tenant_id", "status"})
+
+ SignDurationSeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{
+ Name: "signer_sign_duration_seconds",
+ Help: "Duration of signing requests in seconds",
+ Buckets: prometheus.DefBuckets,
+ }, []string{"tenant_id"})
+
+ RevokeRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
+ Name: "signer_revoke_requests_total",
+ Help: "Total number of revocation requests",
+ }, []string{"tenant_id", "status"})
+
+ AuthFailuresTotal = promauto.NewCounterVec(prometheus.CounterOpts{
+ Name: "signer_auth_failures_total",
+ Help: "Total number of authentication failures",
+ }, []string{"reason"})
+
+ VaultOperationsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
+ Name: "signer_vault_operations_total",
+ Help: "Total number of Vault operations",
+ }, []string{"operation", "status"})
+
+ DBQueryDurationSeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{
+ Name: "signer_db_query_duration_seconds",
+ Help: "Duration of database queries in seconds",
+ Buckets: prometheus.DefBuckets,
+ }, []string{"query"})
+)
diff --git a/signer/internal/metrics/metrics_test.go b/signer/internal/metrics/metrics_test.go
new file mode 100644
index 000000000..c33b8b42b
--- /dev/null
+++ b/signer/internal/metrics/metrics_test.go
@@ -0,0 +1,62 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package metrics
+
+import (
+ "testing"
+
+ "github.com/prometheus/client_golang/prometheus"
+)
+
+func TestMetricsRegistered(t *testing.T) {
+ // Just verify the metrics exist and can be collected
+ SignRequestsTotal.WithLabelValues("test", "success").Inc()
+ SignDurationSeconds.WithLabelValues("test").Observe(0.1)
+ RevokeRequestsTotal.WithLabelValues("test", "success").Inc()
+ AuthFailuresTotal.WithLabelValues("unauthorized").Inc()
+ VaultOperationsTotal.WithLabelValues("get_ca_key", "success").Inc()
+ DBQueryDurationSeconds.WithLabelValues("select").Observe(0.01)
+
+ // Verify they can be gathered without panicking
+ gathering, err := prometheus.DefaultGatherer.Gather()
+ if err != nil {
+ t.Fatalf("failed to gather metrics: %v", err)
+ }
+ if len(gathering) == 0 {
+ t.Error("expected some metric families")
+ }
+
+ // Check that our custom metrics exist
+ found := make(map[string]bool)
+ for _, mf := range gathering {
+ found[mf.GetName()] = true
+ }
+
+ expectedMetrics := []string{
+ "signer_sign_requests_total",
+ "signer_sign_duration_seconds",
+ "signer_revoke_requests_total",
+ "signer_auth_failures_total",
+ "signer_vault_operations_total",
+ "signer_db_query_duration_seconds",
+ }
+
+ for _, name := range expectedMetrics {
+ if !found[name] {
+ t.Errorf("metric %s not found in gathered metrics", name)
+ }
+ }
+}
diff --git a/signer/internal/policy/enforcer.go b/signer/internal/policy/enforcer.go
new file mode 100644
index 000000000..b6d006793
--- /dev/null
+++ b/signer/internal/policy/enforcer.go
@@ -0,0 +1,97 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package policy
+
+import (
+ "fmt"
+ "net"
+ "strings"
+
+ "github.com/apache/airavata-custos/signer/internal/cert"
+ "github.com/apache/airavata-custos/signer/internal/store"
+)
+
+type Enforcer struct {
+ defaultMaxTTL int
+ defaultAllowedTypes []string
+}
+
+func NewEnforcer(defaultMaxTTL int, defaultAllowedTypes []string) *Enforcer {
+ return &Enforcer{
+ defaultMaxTTL: defaultMaxTTL,
+ defaultAllowedTypes: defaultAllowedTypes,
+ }
+}
+
+type PolicyError struct {
+ Message string
+}
+
+func (e *PolicyError) Error() string {
+ return e.Message
+}
+
+func (e *Enforcer) Enforce(ttlSeconds int, sshKeyType string, sourceIP string, clientCfg *store.ClientConfig) error {
+ maxTTL := clientCfg.MaxTTLSeconds
+ if maxTTL == 0 {
+ maxTTL = e.defaultMaxTTL
+ }
+ if ttlSeconds <= 0 {
+ return &PolicyError{Message: fmt.Sprintf("TTL must be greater than 0, got %d", ttlSeconds)}
+ }
+ if ttlSeconds > maxTTL {
+ return &PolicyError{Message: fmt.Sprintf("Requested TTL %d seconds exceeds maximum allowed TTL %d seconds", ttlSeconds, maxTTL)}
+ }
+
+ normalizedType := cert.NormalizeKeyType(sshKeyType)
+ allowedTypes := clientCfg.AllowedKeyTypes
+ if len(allowedTypes) == 0 {
+ allowedTypes = e.defaultAllowedTypes
+ }
+ typeAllowed := false
+ for _, at := range allowedTypes {
+ if strings.EqualFold(at, normalizedType) {
+ typeAllowed = true
+ break
+ }
+ }
+ if !typeAllowed {
+ return &PolicyError{Message: fmt.Sprintf("Key type %q is not allowed. Allowed types: %v", normalizedType, allowedTypes)}
+ }
+
+ if clientCfg.SourceAddressRestriction != nil && *clientCfg.SourceAddressRestriction != "" {
+ _, cidr, err := net.ParseCIDR(*clientCfg.SourceAddressRestriction)
+ if err != nil {
+ return &PolicyError{Message: fmt.Sprintf("Invalid source address restriction CIDR: %s", *clientCfg.SourceAddressRestriction)}
+ }
+ ip := net.ParseIP(sourceIP)
+ if ip == nil {
+ return &PolicyError{Message: fmt.Sprintf("Could not parse source IP: %s", sourceIP)}
+ }
+ if !cidr.Contains(ip) {
+ return &PolicyError{Message: fmt.Sprintf("Source IP %s is not within allowed CIDR %s", sourceIP, *clientCfg.SourceAddressRestriction)}
+ }
+ }
+
+ return nil
+}
+
+func GetCriticalOptions(clientCfg *store.ClientConfig) map[string]string {
+ if clientCfg.CriticalOptions != nil && len(clientCfg.CriticalOptions) > 0 {
+ return clientCfg.CriticalOptions
+ }
+ return nil
+}
diff --git a/signer/internal/policy/enforcer_test.go b/signer/internal/policy/enforcer_test.go
new file mode 100644
index 000000000..cc33de05a
--- /dev/null
+++ b/signer/internal/policy/enforcer_test.go
@@ -0,0 +1,167 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package policy
+
+import (
+ "testing"
+
+ "github.com/apache/airavata-custos/signer/internal/store"
+)
+
+func clientCfg(maxTTL int, keyTypes []string, sourceRestriction *string, critOpts map[string]string) *store.ClientConfig {
+ return &store.ClientConfig{
+ MaxTTLSeconds: maxTTL,
+ AllowedKeyTypes: keyTypes,
+ SourceAddressRestriction: sourceRestriction,
+ CriticalOptions: critOpts,
+ }
+}
+
+func strPtr(s string) *string {
+ return &s
+}
+
+func TestEnforce_TTLWithinLimit(t *testing.T) {
+ e := NewEnforcer(86400, []string{"ed25519", "rsa", "ecdsa"})
+ cfg := clientCfg(86400, []string{"ed25519"}, nil, nil)
+ err := e.Enforce(3600, "ssh-ed25519", "10.0.0.1", cfg)
+ if err != nil {
+ t.Errorf("expected no error, got: %v", err)
+ }
+}
+
+func TestEnforce_TTLExceedsLimit(t *testing.T) {
+ e := NewEnforcer(86400, []string{"ed25519"})
+ cfg := clientCfg(3600, []string{"ed25519"}, nil, nil)
+ err := e.Enforce(7200, "ssh-ed25519", "10.0.0.1", cfg)
+ if err == nil {
+ t.Fatal("expected error for TTL exceeding limit")
+ }
+ pErr, ok := err.(*PolicyError)
+ if !ok {
+ t.Fatalf("expected PolicyError, got %T", err)
+ }
+ if pErr.Message != "Requested TTL 7200 seconds exceeds maximum allowed TTL 3600 seconds" {
+ t.Errorf("unexpected message: %s", pErr.Message)
+ }
+}
+
+func TestEnforce_TTLAtExactLimit(t *testing.T) {
+ e := NewEnforcer(86400, []string{"ed25519"})
+ cfg := clientCfg(3600, []string{"ed25519"}, nil, nil)
+ err := e.Enforce(3600, "ssh-ed25519", "10.0.0.1", cfg)
+ if err != nil {
+ t.Errorf("expected no error, got: %v", err)
+ }
+}
+
+func TestEnforce_NullMaxTTLUsesDefault(t *testing.T) {
+ e := NewEnforcer(86400, []string{"ed25519"})
+ cfg := clientCfg(0, []string{"ed25519"}, nil, nil) // 0 = null equivalent
+ err := e.Enforce(86400, "ssh-ed25519", "10.0.0.1", cfg)
+ if err != nil {
+ t.Errorf("expected no error, got: %v", err)
+ }
+}
+
+func TestEnforce_TTLZero(t *testing.T) {
+ e := NewEnforcer(86400, []string{"ed25519"})
+ cfg := clientCfg(86400, []string{"ed25519"}, nil, nil)
+ err := e.Enforce(0, "ssh-ed25519", "10.0.0.1", cfg)
+ if err == nil {
+ t.Fatal("expected error for TTL 0")
+ }
+}
+
+func TestEnforce_NegativeTTL(t *testing.T) {
+ e := NewEnforcer(86400, []string{"ed25519"})
+ cfg := clientCfg(86400, []string{"ed25519"}, nil, nil)
+ err := e.Enforce(-100, "ssh-ed25519", "10.0.0.1", cfg)
+ if err == nil {
+ t.Fatal("expected error for negative TTL")
+ }
+}
+
+func TestEnforce_DisallowedKeyType(t *testing.T) {
+ e := NewEnforcer(86400, []string{"ed25519"})
+ cfg := clientCfg(86400, []string{"ed25519"}, nil, nil)
+ err := e.Enforce(3600, "ssh-rsa", "10.0.0.1", cfg)
+ if err == nil {
+ t.Fatal("expected error for disallowed key type")
+ }
+ pErr := err.(*PolicyError)
+ if pErr == nil {
+ t.Fatal("expected PolicyError")
+ }
+}
+
+func TestEnforce_AllowedKeyType(t *testing.T) {
+ e := NewEnforcer(86400, []string{"ed25519", "rsa"})
+ cfg := clientCfg(86400, []string{"ed25519", "rsa"}, nil, nil)
+ err := e.Enforce(3600, "ssh-rsa", "10.0.0.1", cfg)
+ if err != nil {
+ t.Errorf("expected no error, got: %v", err)
+ }
+}
+
+func TestEnforce_SourceAddressRejected(t *testing.T) {
+ e := NewEnforcer(86400, []string{"ed25519"})
+ cidr := "10.0.0.0/8"
+ cfg := clientCfg(86400, []string{"ed25519"}, &cidr, nil)
+ err := e.Enforce(3600, "ssh-ed25519", "192.168.1.1", cfg)
+ if err == nil {
+ t.Fatal("expected error for source address outside CIDR")
+ }
+}
+
+func TestEnforce_SourceAddressAccepted(t *testing.T) {
+ e := NewEnforcer(86400, []string{"ed25519"})
+ cidr := "10.0.0.0/8"
+ cfg := clientCfg(86400, []string{"ed25519"}, &cidr, nil)
+ err := e.Enforce(3600, "ssh-ed25519", "10.1.2.3", cfg)
+ if err != nil {
+ t.Errorf("expected no error, got: %v", err)
+ }
+}
+
+func TestEnforce_NoSourceRestriction(t *testing.T) {
+ e := NewEnforcer(86400, []string{"ed25519"})
+ cfg := clientCfg(86400, []string{"ed25519"}, nil, nil)
+ err := e.Enforce(3600, "ssh-ed25519", "192.168.1.1", cfg)
+ if err != nil {
+ t.Errorf("expected no error, got: %v", err)
+ }
+}
+
+func TestGetCriticalOptions(t *testing.T) {
+ opts := map[string]string{"source-address": "10.0.0.0/8"}
+ cfg := clientCfg(86400, nil, nil, opts)
+ result := GetCriticalOptions(cfg)
+ if result == nil {
+ t.Fatal("expected non-nil critical options")
+ }
+ if result["source-address"] != "10.0.0.0/8" {
+ t.Errorf("expected source-address, got %v", result)
+ }
+}
+
+func TestGetCriticalOptions_Nil(t *testing.T) {
+ cfg := clientCfg(86400, nil, nil, nil)
+ result := GetCriticalOptions(cfg)
+ if result != nil {
+ t.Errorf("expected nil critical options, got %v", result)
+ }
+}
diff --git a/signer/internal/policy/key_type.go b/signer/internal/policy/key_type.go
new file mode 100644
index 000000000..9199e9a3b
--- /dev/null
+++ b/signer/internal/policy/key_type.go
@@ -0,0 +1,17 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package policy enforces signing policies (TTL, key types, source addresses, critical options).
+package policy
diff --git a/signer/internal/server/bearer_middleware_test.go b/signer/internal/server/bearer_middleware_test.go
new file mode 100644
index 000000000..e9b1fc455
--- /dev/null
+++ b/signer/internal/server/bearer_middleware_test.go
@@ -0,0 +1,125 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/lestrrat-go/jwx/v2/jwk"
+
+ "github.com/apache/airavata-custos/signer/internal/auth"
+ "github.com/apache/airavata-custos/signer/internal/config"
+ "github.com/apache/airavata-custos/signer/internal/httputil"
+)
+
+// newTestOIDCValidator creates an OIDCValidator with token validation disabled
+// so it returns a default identity without needing real JWKS.
+func newTestOIDCValidator(devMode bool) *auth.OIDCValidator {
+ v := auth.NewOIDCValidator(config.AuthConfig{}, config.DevModeConfig{
+ Enabled: devMode,
+ })
+ if !devMode {
+ // Set a custom JWKS fetcher that returns an empty key set to avoid HTTP calls
+ v.JWKSFetcher = func(ctx context.Context, issuer string) (jwk.Set, error) {
+ return jwk.NewSet(), nil
+ }
+ }
+ return v
+}
+
+func TestBearerAuthMiddleware_MissingHeader(t *testing.T) {
+ validator := newTestOIDCValidator(true)
+ handler := BearerAuthMiddleware(validator)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("GET", "/api/v1/certificates", nil)
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusUnauthorized {
+ t.Errorf("expected 401, got %d", rec.Code)
+ }
+
+ var body map[string]string
+ json.NewDecoder(rec.Body).Decode(&body)
+ if body["error"] != "unauthorized" {
+ t.Errorf("expected error code 'unauthorized', got %q", body["error"])
+ }
+}
+
+func TestBearerAuthMiddleware_InvalidScheme(t *testing.T) {
+ validator := newTestOIDCValidator(true)
+ handler := BearerAuthMiddleware(validator)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("GET", "/api/v1/certificates", nil)
+ req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusUnauthorized {
+ t.Errorf("expected 401, got %d", rec.Code)
+ }
+}
+
+func TestBearerAuthMiddleware_EmptyToken(t *testing.T) {
+ validator := newTestOIDCValidator(true)
+ handler := BearerAuthMiddleware(validator)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("GET", "/api/v1/certificates", nil)
+ req.Header.Set("Authorization", "Bearer ")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusUnauthorized {
+ t.Errorf("expected 401, got %d", rec.Code)
+ }
+}
+
+func TestBearerAuthMiddleware_DisabledValidation(t *testing.T) {
+ // When dev mode is enabled, any token is accepted with default identity
+ validator := newTestOIDCValidator(true)
+ var capturedIdentity *httputil.UserIdentityContext
+
+ handler := BearerAuthMiddleware(validator)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedIdentity = httputil.UserIdentityFromContext(r.Context())
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("GET", "/api/v1/certificates", nil)
+ req.Header.Set("Authorization", "Bearer some-token")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Errorf("expected 200, got %d", rec.Code)
+ }
+
+ if capturedIdentity == nil {
+ t.Fatal("expected user identity in context, got nil")
+ }
+ if capturedIdentity.Subject != "dev-user" {
+ t.Errorf("expected subject 'dev-user', got %q", capturedIdentity.Subject)
+ }
+}
diff --git a/signer/internal/server/cors.go b/signer/internal/server/cors.go
new file mode 100644
index 000000000..383512d0e
--- /dev/null
+++ b/signer/internal/server/cors.go
@@ -0,0 +1,50 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+ "net/http"
+
+ "github.com/apache/airavata-custos/signer/internal/config"
+)
+
+func CORSMiddleware(cfg config.CORSConfig) func(http.Handler) http.Handler {
+ allowedSet := make(map[string]bool, len(cfg.AllowedOrigins))
+ for _, o := range cfg.AllowedOrigins {
+ allowedSet[o] = true
+ }
+
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ origin := r.Header.Get("Origin")
+ if origin != "" && allowedSet[origin] {
+ w.Header().Set("Access-Control-Allow-Origin", origin)
+ w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Client-Id, X-Client-Secret")
+ w.Header().Set("Access-Control-Max-Age", "3600")
+ w.Header().Set("Vary", "Origin")
+ }
+
+ // Handle preflight
+ if r.Method == http.MethodOptions {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ next.ServeHTTP(w, r)
+ })
+ }
+}
diff --git a/signer/internal/server/cors_test.go b/signer/internal/server/cors_test.go
new file mode 100644
index 000000000..731270079
--- /dev/null
+++ b/signer/internal/server/cors_test.go
@@ -0,0 +1,125 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/apache/airavata-custos/signer/internal/config"
+)
+
+func TestCORSMiddleware_AllowedOrigin(t *testing.T) {
+ cfg := config.CORSConfig{
+ Enabled: true,
+ AllowedOrigins: []string{"http://localhost:5173", "https://custos.example.com"},
+ }
+
+ handler := CORSMiddleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("GET", "/api/v1/certificates", nil)
+ req.Header.Set("Origin", "http://localhost:5173")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Errorf("expected 200, got %d", rec.Code)
+ }
+
+ acao := rec.Header().Get("Access-Control-Allow-Origin")
+ if acao != "http://localhost:5173" {
+ t.Errorf("expected ACAO 'http://localhost:5173', got %q", acao)
+ }
+}
+
+func TestCORSMiddleware_DisallowedOrigin(t *testing.T) {
+ cfg := config.CORSConfig{
+ Enabled: true,
+ AllowedOrigins: []string{"http://localhost:5173"},
+ }
+
+ handler := CORSMiddleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("GET", "/api/v1/certificates", nil)
+ req.Header.Set("Origin", "https://evil.com")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ acao := rec.Header().Get("Access-Control-Allow-Origin")
+ if acao != "" {
+ t.Errorf("expected no ACAO header for disallowed origin, got %q", acao)
+ }
+}
+
+func TestCORSMiddleware_PreflightOptions(t *testing.T) {
+ cfg := config.CORSConfig{
+ Enabled: true,
+ AllowedOrigins: []string{"http://localhost:5173"},
+ }
+
+ nextCalled := false
+ handler := CORSMiddleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ nextCalled = true
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("OPTIONS", "/api/v1/certificates", nil)
+ req.Header.Set("Origin", "http://localhost:5173")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNoContent {
+ t.Errorf("expected 204 for preflight, got %d", rec.Code)
+ }
+ if nextCalled {
+ t.Error("next handler should not be called for preflight")
+ }
+
+ acam := rec.Header().Get("Access-Control-Allow-Methods")
+ if acam == "" {
+ t.Error("expected Access-Control-Allow-Methods header")
+ }
+}
+
+func TestCORSMiddleware_NoOriginHeader(t *testing.T) {
+ cfg := config.CORSConfig{
+ Enabled: true,
+ AllowedOrigins: []string{"http://localhost:5173"},
+ }
+
+ handler := CORSMiddleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("GET", "/api/v1/certificates", nil)
+ // No Origin header
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Errorf("expected 200, got %d", rec.Code)
+ }
+
+ acao := rec.Header().Get("Access-Control-Allow-Origin")
+ if acao != "" {
+ t.Errorf("expected no ACAO header without Origin, got %q", acao)
+ }
+}
diff --git a/signer/internal/server/middleware.go b/signer/internal/server/middleware.go
new file mode 100644
index 000000000..ccd70ad3e
--- /dev/null
+++ b/signer/internal/server/middleware.go
@@ -0,0 +1,150 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/apache/airavata-custos/signer/internal/auth"
+ "github.com/apache/airavata-custos/signer/internal/httputil"
+ "github.com/apache/airavata-custos/signer/internal/metrics"
+)
+
+// BearerAuthMiddleware authenticates requests using an OIDC Bearer token in the
+// Authorization header. Used for user-facing endpoints (certificates, userinfo).
+func BearerAuthMiddleware(oidcValidator *auth.OIDCValidator) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ authHeader := r.Header.Get("Authorization")
+ if authHeader == "" {
+ metrics.AuthFailuresTotal.WithLabelValues("missing_bearer").Inc()
+ httputil.WriteJSONError(w, http.StatusUnauthorized, "unauthorized", "Missing Authorization header")
+ return
+ }
+
+ if !strings.HasPrefix(authHeader, "Bearer ") {
+ metrics.AuthFailuresTotal.WithLabelValues("invalid_bearer").Inc()
+ httputil.WriteJSONError(w, http.StatusUnauthorized, "unauthorized", "Authorization header must use Bearer scheme")
+ return
+ }
+
+ token := strings.TrimPrefix(authHeader, "Bearer ")
+ if token == "" {
+ metrics.AuthFailuresTotal.WithLabelValues("empty_bearer").Inc()
+ httputil.WriteJSONError(w, http.StatusUnauthorized, "unauthorized", "Empty Bearer token")
+ return
+ }
+
+ identity, err := oidcValidator.ValidateAccessToken(r.Context(), token)
+ if err != nil {
+ metrics.AuthFailuresTotal.WithLabelValues("invalid_token").Inc()
+ if authErr, ok := err.(*auth.AuthError); ok {
+ httputil.WriteJSONError(w, authErr.Status, authErr.Code, authErr.Message)
+ return
+ }
+ httputil.WriteJSONError(w, http.StatusUnauthorized, "unauthorized", "Token validation failed")
+ return
+ }
+
+ ctx := httputil.WithUserIdentity(r.Context(), &httputil.UserIdentityContext{
+ Issuer: identity.Issuer,
+ Subject: identity.Subject,
+ Email: identity.Email,
+ Principal: identity.Principal,
+ })
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
+
+// ClientAuthMiddleware authenticates requests using X-Client-Id and X-Client-Secret headers.
+func ClientAuthMiddleware(authenticator *auth.ClientAuthenticator) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ clientIDHeader := r.Header.Get("X-Client-Id")
+ secretHeader := r.Header.Get("X-Client-Secret")
+
+ result, err := authenticator.Authenticate(r.Context(), clientIDHeader, secretHeader)
+ if err != nil {
+ if authErr, ok := err.(*auth.AuthError); ok {
+ metrics.AuthFailuresTotal.WithLabelValues(authErr.Code).Inc()
+ httputil.WriteJSONError(w, authErr.Status, authErr.Code, authErr.Message)
+ return
+ }
+ httputil.WriteJSONError(w, http.StatusInternalServerError, "internal_error", "Authentication error")
+ return
+ }
+
+ ctx := httputil.WithClientConfig(r.Context(), result.Config)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
+
+// SourceIPMiddleware extracts the client source IP from X-Forwarded-For or remote address.
+func SourceIPMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ip := extractSourceIP(r)
+ ctx := httputil.WithSourceIP(r.Context(), ip)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+func BodyLimitMiddleware(maxBytes int64) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.ContentLength > maxBytes {
+ httputil.WriteJSONError(w, http.StatusRequestEntityTooLarge, "invalid_request", "Request body too large")
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+func SecurityHeadersMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("X-Frame-Options", "DENY")
+ w.Header().Set("X-XSS-Protection", "0")
+ w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
+ next.ServeHTTP(w, r)
+ })
+}
+
+func extractSourceIP(r *http.Request) string {
+ xff := r.Header.Get("X-Forwarded-For")
+ if xff != "" {
+ parts := strings.SplitN(xff, ",", 2)
+ ip := strings.TrimSpace(parts[0])
+ if ip != "" {
+ return ip
+ }
+ }
+
+ addr := r.RemoteAddr
+ if idx := strings.LastIndex(addr, ":"); idx != -1 {
+ if addr[0] == '[' {
+ if bracketIdx := strings.Index(addr, "]"); bracketIdx != -1 {
+ return addr[1:bracketIdx]
+ }
+ }
+ return addr[:idx]
+ }
+ return addr
+}
diff --git a/signer/internal/server/middleware_test.go b/signer/internal/server/middleware_test.go
new file mode 100644
index 000000000..a8217881e
--- /dev/null
+++ b/signer/internal/server/middleware_test.go
@@ -0,0 +1,124 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/apache/airavata-custos/signer/internal/httputil"
+)
+
+func TestExtractSourceIP_XForwardedFor(t *testing.T) {
+ req := httptest.NewRequest("GET", "/", nil)
+ req.Header.Set("X-Forwarded-For", "10.1.2.3, 192.168.1.1")
+ ip := extractSourceIP(req)
+ if ip != "10.1.2.3" {
+ t.Errorf("expected 10.1.2.3, got %s", ip)
+ }
+}
+
+func TestExtractSourceIP_RemoteAddr(t *testing.T) {
+ req := httptest.NewRequest("GET", "/", nil)
+ req.RemoteAddr = "192.168.1.100:12345"
+ ip := extractSourceIP(req)
+ if ip != "192.168.1.100" {
+ t.Errorf("expected 192.168.1.100, got %s", ip)
+ }
+}
+
+func TestExtractSourceIP_IPv6(t *testing.T) {
+ req := httptest.NewRequest("GET", "/", nil)
+ req.RemoteAddr = "[::1]:12345"
+ ip := extractSourceIP(req)
+ if ip != "::1" {
+ t.Errorf("expected ::1, got %s", ip)
+ }
+}
+
+func TestBodyLimitMiddleware_Rejection(t *testing.T) {
+ handler := BodyLimitMiddleware(100)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ body := strings.Repeat("x", 200)
+ req := httptest.NewRequest("POST", "/", strings.NewReader(body))
+ req.Header.Set("Content-Length", "200")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusRequestEntityTooLarge {
+ t.Errorf("expected 413, got %d", rec.Code)
+ }
+}
+
+func TestBodyLimitMiddleware_Accepted(t *testing.T) {
+ handler := BodyLimitMiddleware(1000)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ body := strings.Repeat("x", 50)
+ req := httptest.NewRequest("POST", "/", strings.NewReader(body))
+ req.Header.Set("Content-Length", "50")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Errorf("expected 200, got %d", rec.Code)
+ }
+}
+
+func TestSecurityHeadersMiddleware(t *testing.T) {
+ handler := SecurityHeadersMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("GET", "/", nil)
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ checks := map[string]string{
+ "X-Content-Type-Options": "nosniff",
+ "X-Frame-Options": "DENY",
+ "X-XSS-Protection": "0",
+ "Referrer-Policy": "strict-origin-when-cross-origin",
+ }
+ for header, want := range checks {
+ got := rec.Header().Get(header)
+ if got != want {
+ t.Errorf("%s: expected %q, got %q", header, want, got)
+ }
+ }
+}
+
+func TestSourceIPMiddleware(t *testing.T) {
+ var extractedIP string
+ handler := SourceIPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ extractedIP = httputil.SourceIPFromContext(r.Context())
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("GET", "/", nil)
+ req.Header.Set("X-Forwarded-For", "1.2.3.4")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if extractedIP != "1.2.3.4" {
+ t.Errorf("expected 1.2.3.4, got %s", extractedIP)
+ }
+}
diff --git a/signer/internal/server/routes.go b/signer/internal/server/routes.go
new file mode 100644
index 000000000..97fbe2a81
--- /dev/null
+++ b/signer/internal/server/routes.go
@@ -0,0 +1,84 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+
+ "github.com/apache/airavata-custos/signer/internal/auth"
+ "github.com/apache/airavata-custos/signer/internal/config"
+)
+
+type Handlers struct {
+ Sign http.HandlerFunc
+ Revoke http.HandlerFunc
+ JWKS http.HandlerFunc
+ CAPublicKey http.HandlerFunc
+ Health http.HandlerFunc
+ Admin http.HandlerFunc
+ Certificates http.HandlerFunc
+ CertificateDetail http.HandlerFunc
+ UserInfo http.HandlerFunc
+}
+
+func NewRouter(
+ cfg *config.Config,
+ authenticator *auth.ClientAuthenticator,
+ oidcValidator *auth.OIDCValidator,
+ handlers Handlers,
+) *chi.Mux {
+ r := chi.NewRouter()
+
+ if cfg.CORS.Enabled {
+ r.Use(CORSMiddleware(cfg.CORS))
+ }
+ r.Use(SecurityHeadersMiddleware)
+ r.Use(SourceIPMiddleware)
+ r.Use(BodyLimitMiddleware(1 << 20)) // 1 MB
+
+ // Health endpoint (no auth)
+ r.Get("/api/v1/health", handlers.Health)
+
+ // Metrics endpoint (no auth)
+ if cfg.Metrics.Enabled {
+ r.Handle(cfg.Metrics.Path, promhttp.Handler())
+ }
+
+ // Client-authenticated routes (machine-to-machine)
+ r.Group(func(r chi.Router) {
+ r.Use(ClientAuthMiddleware(authenticator))
+
+ r.Post("/api/v1/sign", handlers.Sign)
+ r.Post("/api/v1/revoke", handlers.Revoke)
+ r.Get("/api/v1/jwks", handlers.JWKS)
+ r.Get("/api/v1/ca-public-key", handlers.CAPublicKey)
+ r.Post("/api/v1/admin/rotate-ca", handlers.Admin)
+ })
+
+ // User-authenticated routes (OIDC Bearer token)
+ r.Group(func(r chi.Router) {
+ r.Use(BearerAuthMiddleware(oidcValidator))
+
+ r.Get("/api/v1/certificates", handlers.Certificates)
+ r.Get("/api/v1/certificates/{serial}", handlers.CertificateDetail)
+ r.Get("/api/v1/userinfo", handlers.UserInfo)
+ })
+
+ return r
+}
diff --git a/signer/internal/server/server.go b/signer/internal/server/server.go
new file mode 100644
index 000000000..45a684c62
--- /dev/null
+++ b/signer/internal/server/server.go
@@ -0,0 +1,82 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/apache/airavata-custos/signer/internal/config"
+)
+
+// Server wraps the HTTP server with graceful shutdown.
+type Server struct {
+ httpServer *http.Server
+ logger *slog.Logger
+ shutdownTimeout time.Duration
+}
+
+func New(cfg config.ServerConfig, handler http.Handler, logger *slog.Logger) *Server {
+ return &Server{
+ httpServer: &http.Server{
+ Addr: fmt.Sprintf(":%d", cfg.Port),
+ Handler: handler,
+ ReadTimeout: time.Duration(cfg.ReadTimeoutSeconds) * time.Second,
+ WriteTimeout: time.Duration(cfg.WriteTimeoutSeconds) * time.Second,
+ },
+ logger: logger,
+ shutdownTimeout: time.Duration(cfg.ShutdownTimeoutSeconds) * time.Second,
+ }
+}
+
+// ListenAndServe starts the HTTP server and blocks until shutdown signal is received.
+func (s *Server) ListenAndServe() error {
+ errCh := make(chan error, 1)
+ go func() {
+ s.logger.Info("starting HTTP server", "addr", s.httpServer.Addr)
+ if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ errCh <- err
+ }
+ close(errCh)
+ }()
+
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+
+ select {
+ case sig := <-quit:
+ s.logger.Info("received shutdown signal", "signal", sig.String())
+ case err := <-errCh:
+ return fmt.Errorf("server error: %w", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), s.shutdownTimeout)
+ defer cancel()
+
+ s.logger.Info("shutting down server", "timeout", s.shutdownTimeout.String())
+ if err := s.httpServer.Shutdown(ctx); err != nil {
+ return fmt.Errorf("server shutdown: %w", err)
+ }
+
+ s.logger.Info("server stopped")
+ return nil
+}
diff --git a/signer/internal/store/certificate_query.go b/signer/internal/store/certificate_query.go
new file mode 100644
index 000000000..3891920bf
--- /dev/null
+++ b/signer/internal/store/certificate_query.go
@@ -0,0 +1,148 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package store
+
+import (
+ "context"
+ "fmt"
+ "time"
+)
+
+type CertificateWithStatus struct {
+ ID int64
+ TenantID string
+ ClientID string
+ SerialNumber int64
+ KeyID string
+ Principal string
+ UserEmail string
+ PublicKeyFingerprint string
+ CAFingerprint string
+ ValidAfter time.Time
+ ValidBefore time.Time
+ IssuedAt time.Time
+ SourceIP string
+ Revoked bool
+ RevokedAt *time.Time
+ RevocationReason string
+}
+
+type CertificateListResult struct {
+ Certificates []CertificateWithStatus
+ Total int
+}
+
+// ListCertificatesByEmail returns certificates issued to a user email, ordered by
+// issued_at descending (newest first). Includes revocation status via LEFT JOIN.
+func (d *DB) ListCertificatesByEmail(ctx context.Context, email string, limit, offset int) (*CertificateListResult, error) {
+ if limit <= 0 {
+ limit = 20
+ }
+ if limit > 100 {
+ limit = 100
+ }
+ if offset < 0 {
+ offset = 0
+ }
+
+ // Count total matching certificates
+ var total int
+ err := d.QueryRowContext(ctx,
+ `SELECT COUNT(*) FROM certificate_issuance_logs WHERE user_email = ?`,
+ email,
+ ).Scan(&total)
+ if err != nil {
+ return nil, fmt.Errorf("counting certificates: %w", err)
+ }
+
+ // Fetch page with revocation status
+ rows, err := d.QueryContext(ctx,
+ `SELECT
+ c.id, c.tenant_id, c.client_id, c.serial_number, c.key_id,
+ c.principal, COALESCE(c.user_email, ''), c.public_key_fingerprint, c.ca_fingerprint,
+ c.valid_after, c.valid_before, c.issued_at, COALESCE(c.source_ip, ''),
+ CASE WHEN r.id IS NOT NULL THEN TRUE ELSE FALSE END AS revoked,
+ r.revoked_at,
+ COALESCE(r.reason, '') AS revocation_reason
+ FROM certificate_issuance_logs c
+ LEFT JOIN revocation_events r ON r.serial_number = c.serial_number
+ WHERE c.user_email = ?
+ ORDER BY c.issued_at DESC
+ LIMIT ? OFFSET ?`,
+ email, limit, offset,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("querying certificates: %w", err)
+ }
+ defer rows.Close()
+
+ var certs []CertificateWithStatus
+ for rows.Next() {
+ var cert CertificateWithStatus
+ var revokedAt *time.Time
+
+ if err := rows.Scan(
+ &cert.ID, &cert.TenantID, &cert.ClientID, &cert.SerialNumber, &cert.KeyID,
+ &cert.Principal, &cert.UserEmail, &cert.PublicKeyFingerprint, &cert.CAFingerprint,
+ &cert.ValidAfter, &cert.ValidBefore, &cert.IssuedAt, &cert.SourceIP,
+ &cert.Revoked, &revokedAt, &cert.RevocationReason,
+ ); err != nil {
+ return nil, fmt.Errorf("scanning certificate row: %w", err)
+ }
+
+ cert.RevokedAt = revokedAt
+ certs = append(certs, cert)
+ }
+
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterating certificate rows: %w", err)
+ }
+
+ return &CertificateListResult{
+ Certificates: certs,
+ Total: total,
+ }, nil
+}
+
+func (d *DB) GetCertificateBySerial(ctx context.Context, serial int64) (*CertificateWithStatus, error) {
+ var cert CertificateWithStatus
+ var revokedAt *time.Time
+
+ err := d.QueryRowContext(ctx,
+ `SELECT
+ c.id, c.tenant_id, c.client_id, c.serial_number, c.key_id,
+ c.principal, COALESCE(c.user_email, ''), c.public_key_fingerprint, c.ca_fingerprint,
+ c.valid_after, c.valid_before, c.issued_at, COALESCE(c.source_ip, ''),
+ CASE WHEN r.id IS NOT NULL THEN TRUE ELSE FALSE END AS revoked,
+ r.revoked_at,
+ COALESCE(r.reason, '') AS revocation_reason
+ FROM certificate_issuance_logs c
+ LEFT JOIN revocation_events r ON r.serial_number = c.serial_number
+ WHERE c.serial_number = ?`,
+ serial,
+ ).Scan(
+ &cert.ID, &cert.TenantID, &cert.ClientID, &cert.SerialNumber, &cert.KeyID,
+ &cert.Principal, &cert.UserEmail, &cert.PublicKeyFingerprint, &cert.CAFingerprint,
+ &cert.ValidAfter, &cert.ValidBefore, &cert.IssuedAt, &cert.SourceIP,
+ &cert.Revoked, &revokedAt, &cert.RevocationReason,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("querying certificate: %w", err)
+ }
+
+ cert.RevokedAt = revokedAt
+ return &cert, nil
+}
diff --git a/signer/internal/store/client_config.go b/signer/internal/store/client_config.go
new file mode 100644
index 000000000..7c2d95c48
--- /dev/null
+++ b/signer/internal/store/client_config.go
@@ -0,0 +1,88 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package store
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+)
+
+type ClientConfig struct {
+ TenantID string
+ ClientID string
+ ClientSecret string // BCrypt hash
+ TargetHost string
+ TargetPort int
+ MaxTTLSeconds int
+ AllowedKeyTypes []string
+ SourceAddressRestriction *string
+ CriticalOptions map[string]string
+ Extensions map[string]string
+ Enabled bool
+}
+
+func (d *DB) GetClientConfig(ctx context.Context, tenantID, clientID string) (*ClientConfig, error) {
+ var (
+ cc ClientConfig
+ allowedKeyTypesJSON []byte
+ sourceAddressRestriction sql.NullString
+ criticalOptionsJSON sql.NullString
+ extensionsJSON sql.NullString
+ )
+
+ err := d.QueryRowContext(ctx,
+ `SELECT tenant_id, client_id, client_secret, target_host, target_port,
+ max_ttl_seconds, allowed_key_types, source_address_restriction,
+ critical_options, extensions, enabled
+ FROM client_ssh_configs
+ WHERE tenant_id = ? AND client_id = ?`,
+ tenantID, clientID,
+ ).Scan(
+ &cc.TenantID, &cc.ClientID, &cc.ClientSecret, &cc.TargetHost, &cc.TargetPort,
+ &cc.MaxTTLSeconds, &allowedKeyTypesJSON, &sourceAddressRestriction,
+ &criticalOptionsJSON, &extensionsJSON, &cc.Enabled,
+ )
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("querying client config: %w", err)
+ }
+
+ if err := json.Unmarshal(allowedKeyTypesJSON, &cc.AllowedKeyTypes); err != nil {
+ return nil, fmt.Errorf("unmarshaling allowed_key_types: %w", err)
+ }
+
+ if sourceAddressRestriction.Valid {
+ cc.SourceAddressRestriction = &sourceAddressRestriction.String
+ }
+
+ if criticalOptionsJSON.Valid && criticalOptionsJSON.String != "" {
+ if err := json.Unmarshal([]byte(criticalOptionsJSON.String), &cc.CriticalOptions); err != nil {
+ return nil, fmt.Errorf("unmarshaling critical_options: %w", err)
+ }
+ }
+
+ if extensionsJSON.Valid && extensionsJSON.String != "" {
+ if err := json.Unmarshal([]byte(extensionsJSON.String), &cc.Extensions); err != nil {
+ return nil, fmt.Errorf("unmarshaling extensions: %w", err)
+ }
+ }
+
+ return &cc, nil
+}
diff --git a/signer/internal/store/db.go b/signer/internal/store/db.go
new file mode 100644
index 000000000..ec3b656b9
--- /dev/null
+++ b/signer/internal/store/db.go
@@ -0,0 +1,57 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package store provides database access for client configs, issuance logs, and revocation events.
+package store
+
+import (
+ "database/sql"
+ "fmt"
+ "time"
+
+ _ "github.com/go-sql-driver/mysql"
+
+ "github.com/apache/airavata-custos/signer/internal/config"
+)
+
+type DB struct {
+ *sql.DB
+}
+
+func NewDB(cfg config.DatabaseConfig) (*DB, error) {
+ dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&loc=UTC&charset=utf8mb4",
+ cfg.Username, cfg.Password, cfg.Host, cfg.Port, cfg.Name)
+
+ db, err := sql.Open("mysql", dsn)
+ if err != nil {
+ return nil, fmt.Errorf("opening database: %w", err)
+ }
+
+ db.SetMaxOpenConns(cfg.MaxOpenConns)
+ db.SetMaxIdleConns(cfg.MaxIdleConns)
+ db.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetimeSeconds) * time.Second)
+
+ if err := db.Ping(); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("pinging database: %w", err)
+ }
+
+ return &DB{db}, nil
+}
+
+func (d *DB) Ping() error {
+ var n int
+ return d.QueryRow("SELECT 1").Scan(&n)
+}
diff --git a/signer/internal/store/issuance_log.go b/signer/internal/store/issuance_log.go
new file mode 100644
index 000000000..823656eca
--- /dev/null
+++ b/signer/internal/store/issuance_log.go
@@ -0,0 +1,65 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package store
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "time"
+)
+
+type IssuanceLog struct {
+ TenantID string
+ ClientID string
+ SerialNumber int64
+ KeyID string
+ Principal string
+ UserEmail string
+ PublicKeyFingerprint string
+ CAFingerprint string
+ ValidAfter time.Time
+ ValidBefore time.Time
+ SourceIP string
+ UserAccessTokenHash string
+ RequestMetadata map[string]interface{}
+}
+
+// InsertIssuanceLog writes a certificate issuance entry. Fails on duplicate serial_number.
+func (d *DB) InsertIssuanceLog(ctx context.Context, log *IssuanceLog) error {
+ var metadataJSON []byte
+ if log.RequestMetadata != nil {
+ var err error
+ metadataJSON, err = json.Marshal(log.RequestMetadata)
+ if err != nil {
+ return fmt.Errorf("marshaling request_metadata: %w", err)
+ }
+ }
+
+ _, err := d.ExecContext(ctx,
+ `INSERT INTO certificate_issuance_logs
+ (tenant_id, client_id, serial_number, key_id, principal, user_email, public_key_fingerprint,
+ ca_fingerprint, valid_after, valid_before, source_ip, user_access_token_hash, request_metadata)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ log.TenantID, log.ClientID, log.SerialNumber, log.KeyID, log.Principal,
+ log.UserEmail, log.PublicKeyFingerprint, log.CAFingerprint, log.ValidAfter, log.ValidBefore,
+ log.SourceIP, log.UserAccessTokenHash, metadataJSON,
+ )
+ if err != nil {
+ return fmt.Errorf("inserting issuance log: %w", err)
+ }
+ return nil
+}
diff --git a/signer/internal/store/revocation.go b/signer/internal/store/revocation.go
new file mode 100644
index 000000000..f625e3547
--- /dev/null
+++ b/signer/internal/store/revocation.go
@@ -0,0 +1,58 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package store
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+)
+
+type RevocationEvent struct {
+ TenantID string
+ ClientID string
+ SerialNumber *int64
+ KeyID *string
+ CAFingerprint *string
+ Reason string
+ RevokedBy string
+}
+
+func (d *DB) InsertRevocationEvent(ctx context.Context, ev *RevocationEvent) error {
+ var serialNumber sql.NullInt64
+ if ev.SerialNumber != nil {
+ serialNumber = sql.NullInt64{Int64: *ev.SerialNumber, Valid: true}
+ }
+ var keyID sql.NullString
+ if ev.KeyID != nil {
+ keyID = sql.NullString{String: *ev.KeyID, Valid: true}
+ }
+ var caFingerprint sql.NullString
+ if ev.CAFingerprint != nil {
+ caFingerprint = sql.NullString{String: *ev.CAFingerprint, Valid: true}
+ }
+
+ _, err := d.ExecContext(ctx,
+ `INSERT INTO revocation_events
+ (tenant_id, client_id, serial_number, key_id, ca_fingerprint, reason, revoked_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ ev.TenantID, ev.ClientID, serialNumber, keyID, caFingerprint, ev.Reason, ev.RevokedBy,
+ )
+ if err != nil {
+ return fmt.Errorf("inserting revocation event: %w", err)
+ }
+ return nil
+}
diff --git a/signer/internal/validation/comanage.go b/signer/internal/validation/comanage.go
new file mode 100644
index 000000000..b4ddaac50
--- /dev/null
+++ b/signer/internal/validation/comanage.go
@@ -0,0 +1,30 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package validation
+
+// COmanageValidator is a stub — full COmanage Registry integration is deferred.
+type COmanageValidator struct{}
+
+func NewCOmanageValidator() *COmanageValidator {
+ return &COmanageValidator{}
+}
+
+func (v *COmanageValidator) Validate(tenantID, clientID, principal, identitySubject string) (*ValidationResult, error) {
+ return nil, &ValidationError{
+ Message: "Requested principal is not allowed",
+ ReasonCode: "COMANAGE_NOT_IMPLEMENTED",
+ }
+}
diff --git a/signer/internal/validation/noop.go b/signer/internal/validation/noop.go
new file mode 100644
index 000000000..251570624
--- /dev/null
+++ b/signer/internal/validation/noop.go
@@ -0,0 +1,30 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package validation
+
+// NoOpValidator — for development/testing use only.
+type NoOpValidator struct{}
+
+func NewNoOpValidator() *NoOpValidator {
+ return &NoOpValidator{}
+}
+
+func (v *NoOpValidator) Validate(tenantID, clientID, principal, identitySubject string) (*ValidationResult, error) {
+ return &ValidationResult{
+ Allowed: true,
+ ValidatedPrincipal: principal,
+ }, nil
+}
diff --git a/signer/internal/validation/validator.go b/signer/internal/validation/validator.go
new file mode 100644
index 000000000..d60ba3a21
--- /dev/null
+++ b/signer/internal/validation/validator.go
@@ -0,0 +1,38 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package validation provides a pluggable PrincipalValidator interface.
+package validation
+
+type PrincipalValidator interface {
+ // Validate checks if the requested principal is authorized. The returned
+ // principal may differ from the requested one.
+ Validate(tenantID, clientID, principal, identitySubject string) (*ValidationResult, error)
+}
+
+type ValidationResult struct {
+ Allowed bool
+ ValidatedPrincipal string
+ ReasonCode string
+}
+
+type ValidationError struct {
+ Message string
+ ReasonCode string
+}
+
+func (e *ValidationError) Error() string {
+ return e.Message
+}
diff --git a/signer/internal/validation/validator_test.go b/signer/internal/validation/validator_test.go
new file mode 100644
index 000000000..3eaaff6c9
--- /dev/null
+++ b/signer/internal/validation/validator_test.go
@@ -0,0 +1,65 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package validation
+
+import (
+ "testing"
+)
+
+func TestNoOpValidator_AllowsAll(t *testing.T) {
+ v := NewNoOpValidator()
+ result, err := v.Validate("t1", "c1", "anyuser", "sub123")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !result.Allowed {
+ t.Error("expected allowed")
+ }
+ if result.ValidatedPrincipal != "anyuser" {
+ t.Errorf("expected anyuser, got %s", result.ValidatedPrincipal)
+ }
+}
+
+func TestCOmanageValidator_AlwaysDenies(t *testing.T) {
+ v := NewCOmanageValidator()
+ result, err := v.Validate("t1", "c1", "user", "sub123")
+ if result != nil {
+ t.Error("expected nil result")
+ }
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ valErr, ok := err.(*ValidationError)
+ if !ok {
+ t.Fatalf("expected ValidationError, got %T", err)
+ }
+ if valErr.ReasonCode != "COMANAGE_NOT_IMPLEMENTED" {
+ t.Errorf("expected COMANAGE_NOT_IMPLEMENTED, got %s", valErr.ReasonCode)
+ }
+ if valErr.Message != "Requested principal is not allowed" {
+ t.Errorf("expected 'Requested principal is not allowed', got %s", valErr.Message)
+ }
+}
+
+func TestValidationError_Interface(t *testing.T) {
+ err := &ValidationError{
+ Message: "Requested principal is not allowed",
+ ReasonCode: "NOT_ALLOWED",
+ }
+ if err.Error() != "Requested principal is not allowed" {
+ t.Errorf("unexpected error message: %s", err.Error())
+ }
+}
diff --git a/signer/internal/vault/client.go b/signer/internal/vault/client.go
new file mode 100644
index 000000000..856b5b48c
--- /dev/null
+++ b/signer/internal/vault/client.go
@@ -0,0 +1,390 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package vault provides Vault KV v2 operations for CA key storage and serial counters.
+package vault
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/x509"
+ "encoding/json"
+ "encoding/pem"
+ "fmt"
+ "strconv"
+ "time"
+
+ vaultapi "github.com/hashicorp/vault/api"
+
+ "github.com/apache/airavata-custos/signer/internal/config"
+)
+
+type CAKeyPair struct {
+ PrivateKey []byte // PEM-encoded private key
+ PublicKey []byte // PEM-encoded public key
+ Algorithm string // e.g., "ed25519"
+ CreatedAt string
+}
+
+type CAMetadata struct {
+ SerialCounter int64
+ LastRotationAt string
+ NextRotationAt string
+ RotationPeriodH int
+ OverlapHours int
+}
+
+type Client struct {
+ client *vaultapi.Client
+ mountPath string
+ rotationCfg config.RotationConfig
+}
+
+func NewClient(cfg config.VaultConfig, rotationCfg config.RotationConfig) (*Client, error) {
+ vaultCfg := vaultapi.DefaultConfig()
+ vaultCfg.Address = cfg.Address
+ vaultCfg.Timeout = time.Duration(cfg.TimeoutSeconds) * time.Second
+
+ client, err := vaultapi.NewClient(vaultCfg)
+ if err != nil {
+ return nil, fmt.Errorf("creating vault client: %w", err)
+ }
+ client.SetToken(cfg.Token)
+
+ return &Client{
+ client: client,
+ mountPath: cfg.MountPath,
+ rotationCfg: rotationCfg,
+ }, nil
+}
+
+func (c *Client) Healthy(ctx context.Context) error {
+ _, err := c.client.Sys().HealthWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("vault health check: %w", err)
+ }
+ return nil
+}
+
+func (c *Client) kvPath(parts ...string) string {
+ path := c.mountPath + "/data"
+ for _, p := range parts {
+ path += "/" + p
+ }
+ return path
+}
+
+// GetCurrentCAKey returns the current CA key, auto-creating one if it doesn't exist.
+func (c *Client) GetCurrentCAKey(ctx context.Context, tenantID, clientID string) (*CAKeyPair, error) {
+ kp, err := c.readCAKey(ctx, tenantID, clientID, "current")
+ if err != nil {
+ return nil, err
+ }
+ if kp != nil {
+ return kp, nil
+ }
+
+ kp, err = generateEd25519CAKey()
+ if err != nil {
+ return nil, err
+ }
+
+ if err := c.writeCAKey(ctx, tenantID, clientID, "current", kp); err != nil {
+ return nil, err
+ }
+
+ if err := c.initMetadata(ctx, tenantID, clientID); err != nil {
+ return nil, err
+ }
+
+ return kp, nil
+}
+
+// GetNextCAKey returns the next CA key, or nil if none exists.
+func (c *Client) GetNextCAKey(ctx context.Context, tenantID, clientID string) (*CAKeyPair, error) {
+ return c.readCAKey(ctx, tenantID, clientID, "next")
+}
+
+// IncrementSerialCounter atomically reads, increments, and writes the serial
+// counter using Vault KV v2 CAS (Check-and-Set) to prevent lost updates.
+func (c *Client) IncrementSerialCounter(ctx context.Context, tenantID, clientID string) (int64, error) {
+ meta, version, err := c.readMetadataWithVersion(ctx, tenantID, clientID)
+ if err != nil {
+ return 0, err
+ }
+ if meta == nil {
+ if err := c.initMetadata(ctx, tenantID, clientID); err != nil {
+ return 0, err
+ }
+ return 1, nil
+ }
+
+ newSerial := meta.SerialCounter + 1
+ meta.SerialCounter = newSerial
+ if err := c.writeMetadataCAS(ctx, tenantID, clientID, meta, version); err != nil {
+ return 0, err
+ }
+ return newSerial, nil
+}
+
+// RotateCA promotes next to current and generates a new next key.
+func (c *Client) RotateCA(ctx context.Context, tenantID, clientID string) (currentFP, nextFP string, err error) {
+ nextKey, err := c.readCAKey(ctx, tenantID, clientID, "next")
+ if err != nil {
+ return "", "", err
+ }
+
+ if nextKey != nil {
+ // Promote next to current
+ if err := c.writeCAKey(ctx, tenantID, clientID, "current", nextKey); err != nil {
+ return "", "", err
+ }
+ } else {
+ // Generate fresh current
+ newCurrent, err := generateEd25519CAKey()
+ if err != nil {
+ return "", "", err
+ }
+ if err := c.writeCAKey(ctx, tenantID, clientID, "current", newCurrent); err != nil {
+ return "", "", err
+ }
+ }
+
+ // Generate new next key
+ newNext, err := generateEd25519CAKey()
+ if err != nil {
+ return "", "", err
+ }
+ if err := c.writeCAKey(ctx, tenantID, clientID, "next", newNext); err != nil {
+ return "", "", err
+ }
+
+ now := time.Now().UTC()
+ meta, err := c.readMetadata(ctx, tenantID, clientID)
+ if err != nil {
+ return "", "", err
+ }
+ if meta == nil {
+ meta = &CAMetadata{SerialCounter: 0}
+ }
+ meta.LastRotationAt = now.Format(time.RFC3339)
+ meta.NextRotationAt = now.Add(time.Duration(c.rotationCfg.PeriodHours) * time.Hour).Format(time.RFC3339)
+ meta.RotationPeriodH = c.rotationCfg.PeriodHours
+ meta.OverlapHours = c.rotationCfg.OverlapHours
+ if err := c.writeMetadata(ctx, tenantID, clientID, meta); err != nil {
+ return "", "", err
+ }
+
+ // Read back keys to get fingerprints
+ currentKey, err := c.readCAKey(ctx, tenantID, clientID, "current")
+ if err != nil {
+ return "", "", err
+ }
+
+ return string(currentKey.PublicKey), string(newNext.PublicKey), nil
+}
+
+func (c *Client) GetMetadata(ctx context.Context, tenantID, clientID string) (*CAMetadata, error) {
+ return c.readMetadata(ctx, tenantID, clientID)
+}
+
+func (c *Client) readCAKey(ctx context.Context, tenantID, clientID, slot string) (*CAKeyPair, error) {
+ path := c.kvPath(tenantID, clientID, slot)
+ secret, err := c.client.Logical().ReadWithContext(ctx, path)
+ if err != nil {
+ return nil, fmt.Errorf("reading vault path %s: %w", path, err)
+ }
+ if secret == nil || secret.Data == nil {
+ return nil, nil
+ }
+
+ data, ok := secret.Data["data"].(map[string]interface{})
+ if !ok || data == nil {
+ return nil, nil
+ }
+
+ kp := &CAKeyPair{}
+ if v, ok := data["private_key"].(string); ok {
+ kp.PrivateKey = []byte(v)
+ }
+ if v, ok := data["public_key"].(string); ok {
+ kp.PublicKey = []byte(v)
+ }
+ if v, ok := data["algorithm"].(string); ok {
+ kp.Algorithm = v
+ }
+ if v, ok := data["created_at"].(string); ok {
+ kp.CreatedAt = v
+ }
+
+ if len(kp.PrivateKey) == 0 {
+ return nil, nil
+ }
+ return kp, nil
+}
+
+func (c *Client) writeCAKey(ctx context.Context, tenantID, clientID, slot string, kp *CAKeyPair) error {
+ path := c.kvPath(tenantID, clientID, slot)
+ data := map[string]interface{}{
+ "data": map[string]interface{}{
+ "private_key": string(kp.PrivateKey),
+ "public_key": string(kp.PublicKey),
+ "algorithm": kp.Algorithm,
+ "created_at": kp.CreatedAt,
+ },
+ }
+ _, err := c.client.Logical().WriteWithContext(ctx, path, data)
+ if err != nil {
+ return fmt.Errorf("writing vault path %s: %w", path, err)
+ }
+ return nil
+}
+
+func (c *Client) readMetadata(ctx context.Context, tenantID, clientID string) (*CAMetadata, error) {
+ meta, _, err := c.readMetadataWithVersion(ctx, tenantID, clientID)
+ return meta, err
+}
+
+func (c *Client) readMetadataWithVersion(ctx context.Context, tenantID, clientID string) (*CAMetadata, int, error) {
+ path := c.kvPath(tenantID, clientID, "metadata")
+ secret, err := c.client.Logical().ReadWithContext(ctx, path)
+ if err != nil {
+ return nil, 0, fmt.Errorf("reading vault metadata: %w", err)
+ }
+ if secret == nil || secret.Data == nil {
+ return nil, 0, nil
+ }
+
+ // Extract KV v2 version from metadata.
+ // The Vault Go client decodes numbers as json.Number, not float64.
+ version := 0
+ if md, ok := secret.Data["metadata"].(map[string]interface{}); ok {
+ if v, ok := md["version"].(json.Number); ok {
+ if n, err := v.Int64(); err == nil {
+ version = int(n)
+ }
+ }
+ }
+
+ data, ok := secret.Data["data"].(map[string]interface{})
+ if !ok || data == nil {
+ return nil, 0, nil
+ }
+
+ meta := &CAMetadata{}
+ if v, ok := data["serial_counter"].(string); ok {
+ meta.SerialCounter, _ = strconv.ParseInt(v, 10, 64)
+ }
+ if v, ok := data["serial_counter"].(float64); ok {
+ meta.SerialCounter = int64(v)
+ }
+ if v, ok := data["last_rotation_at"].(string); ok {
+ meta.LastRotationAt = v
+ }
+ if v, ok := data["next_rotation_at"].(string); ok {
+ meta.NextRotationAt = v
+ }
+ if v, ok := data["rotation_period_hours"].(string); ok {
+ meta.RotationPeriodH, _ = strconv.Atoi(v)
+ }
+ if v, ok := data["rotation_period_hours"].(float64); ok {
+ meta.RotationPeriodH = int(v)
+ }
+ if v, ok := data["overlap_hours"].(string); ok {
+ meta.OverlapHours, _ = strconv.Atoi(v)
+ }
+ if v, ok := data["overlap_hours"].(float64); ok {
+ meta.OverlapHours = int(v)
+ }
+ return meta, version, nil
+}
+
+func (c *Client) writeMetadata(ctx context.Context, tenantID, clientID string, meta *CAMetadata) error {
+ path := c.kvPath(tenantID, clientID, "metadata")
+ data := map[string]interface{}{
+ "data": map[string]interface{}{
+ "serial_counter": strconv.FormatInt(meta.SerialCounter, 10),
+ "last_rotation_at": meta.LastRotationAt,
+ "next_rotation_at": meta.NextRotationAt,
+ "rotation_period_hours": strconv.Itoa(meta.RotationPeriodH),
+ "overlap_hours": strconv.Itoa(meta.OverlapHours),
+ },
+ }
+ _, err := c.client.Logical().WriteWithContext(ctx, path, data)
+ if err != nil {
+ return fmt.Errorf("writing vault metadata: %w", err)
+ }
+ return nil
+}
+
+func (c *Client) writeMetadataCAS(ctx context.Context, tenantID, clientID string, meta *CAMetadata, cas int) error {
+ path := c.kvPath(tenantID, clientID, "metadata")
+ data := map[string]interface{}{
+ "options": map[string]interface{}{
+ "cas": cas,
+ },
+ "data": map[string]interface{}{
+ "serial_counter": strconv.FormatInt(meta.SerialCounter, 10),
+ "last_rotation_at": meta.LastRotationAt,
+ "next_rotation_at": meta.NextRotationAt,
+ "rotation_period_hours": strconv.Itoa(meta.RotationPeriodH),
+ "overlap_hours": strconv.Itoa(meta.OverlapHours),
+ },
+ }
+ _, err := c.client.Logical().WriteWithContext(ctx, path, data)
+ if err != nil {
+ return fmt.Errorf("writing vault metadata (CAS=%d): %w", cas, err)
+ }
+ return nil
+}
+
+func (c *Client) initMetadata(ctx context.Context, tenantID, clientID string) error {
+ now := time.Now().UTC()
+ meta := &CAMetadata{
+ SerialCounter: 0,
+ LastRotationAt: now.Format(time.RFC3339),
+ NextRotationAt: now.Add(time.Duration(c.rotationCfg.PeriodHours) * time.Hour).Format(time.RFC3339),
+ RotationPeriodH: c.rotationCfg.PeriodHours,
+ OverlapHours: c.rotationCfg.OverlapHours,
+ }
+ return c.writeMetadata(ctx, tenantID, clientID, meta)
+}
+
+func generateEd25519CAKey() (*CAKeyPair, error) {
+ pub, priv, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ return nil, fmt.Errorf("generating ed25519 key: %w", err)
+ }
+
+ privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
+ if err != nil {
+ return nil, fmt.Errorf("marshaling private key: %w", err)
+ }
+ privPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
+
+ pubBytes, err := x509.MarshalPKIXPublicKey(pub)
+ if err != nil {
+ return nil, fmt.Errorf("marshaling public key: %w", err)
+ }
+ pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
+
+ return &CAKeyPair{
+ PrivateKey: privPEM,
+ PublicKey: pubPEM,
+ Algorithm: "ed25519",
+ CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ }, nil
+}
diff --git a/signer/main.go b/signer/main.go
new file mode 100644
index 000000000..f5f5c13a6
--- /dev/null
+++ b/signer/main.go
@@ -0,0 +1,218 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+ "strings"
+
+ "github.com/apache/airavata-custos/signer/internal/audit"
+ "github.com/apache/airavata-custos/signer/internal/auth"
+ "github.com/apache/airavata-custos/signer/internal/config"
+ "github.com/apache/airavata-custos/signer/internal/handler"
+ "github.com/apache/airavata-custos/signer/internal/policy"
+ "github.com/apache/airavata-custos/signer/internal/server"
+ "github.com/apache/airavata-custos/signer/internal/store"
+ "github.com/apache/airavata-custos/signer/internal/validation"
+ "github.com/apache/airavata-custos/signer/internal/vault"
+ "github.com/golang-migrate/migrate/v4"
+ _ "github.com/golang-migrate/migrate/v4/database/mysql"
+ _ "github.com/golang-migrate/migrate/v4/source/file"
+)
+
+const usage = `Custos Signer Service - SSH certificate signing service.
+
+Usage:
+ custos-signer <command> [options]
+
+Commands:
+ serve Start the HTTP server (default)
+ migrate Apply pending database migrations and exit
+
+Options:
+ --config string Path to configuration file (default "config.yaml")
+ --auto-migrate Automatically apply migrations on startup (serve only)
+`
+
+func main() {
+ cmd := "serve"
+ args := os.Args[1:]
+ if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
+ cmd = args[0]
+ args = args[1:]
+ }
+
+ configPath := "config.yaml"
+ autoMigrate := false
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "--config":
+ if i+1 < len(args) {
+ configPath = args[i+1]
+ i++
+ }
+ case "--auto-migrate":
+ autoMigrate = true
+ case "--help", "-h":
+ fmt.Print(usage)
+ os.Exit(0)
+ }
+ }
+
+ cfg, err := config.Load(configPath)
+ if err != nil {
+ slog.Error("failed to load configuration", "error", err)
+ os.Exit(1)
+ }
+
+ logger := setupLogger(cfg.Logging)
+ slog.SetDefault(logger)
+
+ if cfg.DevMode.Enabled {
+ logger.Warn("DEV MODE ENABLED - OIDC token validation is disabled. DO NOT use in production.",
+ "default_email", cfg.DevMode.DefaultEmail)
+ }
+
+ switch cmd {
+ case "migrate":
+ runMigrate(cfg, logger)
+ logger.Info("migrations complete")
+ os.Exit(0)
+ case "serve":
+ runServer(cfg, logger, autoMigrate)
+ default:
+ fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", cmd)
+ fmt.Print(usage)
+ os.Exit(1)
+ }
+}
+
+// Applies pending migrations and exits.
+// Invoked by the "migrate" subcommand and also reused by --auto-migrate in serve mode.
+func runMigrate(cfg *config.Config, logger *slog.Logger) {
+ dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s",
+ cfg.Database.Username, cfg.Database.Password,
+ cfg.Database.Host, cfg.Database.Port, cfg.Database.Name)
+ m, err := migrate.New("file://migrations", "mysql://"+dsn)
+ if err != nil {
+ logger.Error("failed to create migrate instance", "error", err)
+ os.Exit(1)
+ }
+ if err := m.Up(); err != nil {
+ if errors.Is(err, migrate.ErrNoChange) {
+ logger.Info("database migrations: no change")
+ } else {
+ logger.Error("failed to run database migrations", "error", err)
+ os.Exit(1)
+ }
+ } else {
+ logger.Info("database migrations applied successfully")
+ }
+}
+
+func runServer(cfg *config.Config, logger *slog.Logger, autoMigrate bool) {
+ db, err := store.NewDB(cfg.Database)
+ if err != nil {
+ logger.Error("failed to connect to database", "error", err)
+ os.Exit(1)
+ }
+ defer db.Close()
+ logger.Info("connected to database")
+
+ if autoMigrate {
+ logger.Info("auto-migrate enabled, applying pending migrations")
+ runMigrate(cfg, logger)
+ }
+
+ vaultClient, err := vault.NewClient(cfg.Vault, cfg.Signer.CA.Rotation)
+ if err != nil {
+ logger.Error("failed to create vault client", "error", err)
+ os.Exit(1)
+ }
+ logger.Info("vault client initialized")
+
+ authenticator := auth.NewClientAuthenticator(db)
+ oidcValidator := auth.NewOIDCValidator(cfg.Signer.Auth, cfg.DevMode)
+ policyEnforcer := policy.NewEnforcer(cfg.Signer.Policy.Defaults.MaxTTLSeconds, cfg.Signer.Policy.Defaults.AllowedKeyTypes)
+ auditLogger := audit.NewLogger(db, logger)
+
+ var principalValidator validation.PrincipalValidator
+ switch strings.ToLower(cfg.Signer.Validation.PrincipalValidator) {
+ case "comanage":
+ principalValidator = validation.NewCOmanageValidator()
+ logger.Info("using COmanage principal validator (stub)")
+ default:
+ principalValidator = validation.NewNoOpValidator()
+ logger.Info("using NoOp principal validator")
+ }
+
+ signHandler := handler.NewSignHandler(oidcValidator, policyEnforcer, principalValidator, vaultClient, auditLogger, logger)
+ revokeHandler := handler.NewRevokeHandler(auditLogger, logger)
+ jwksHandler := handler.NewJWKSHandler(vaultClient, logger)
+ caPublicKeyHandler := handler.NewCAPublicKeyHandler(vaultClient, logger)
+ healthHandler := handler.NewHealthHandler(db, vaultClient)
+ adminHandler := handler.NewAdminHandler(vaultClient, logger)
+ certificatesHandler := handler.NewCertificatesHandler(db, logger)
+ userInfoHandler := handler.NewUserInfoHandler()
+
+ handlers := server.Handlers{
+ Sign: signHandler.Handle,
+ Revoke: revokeHandler.Handle,
+ JWKS: jwksHandler.Handle,
+ CAPublicKey: caPublicKeyHandler.Handle,
+ Health: healthHandler.Handle,
+ Admin: adminHandler.Handle,
+ Certificates: certificatesHandler.HandleList,
+ CertificateDetail: certificatesHandler.HandleGet,
+ UserInfo: userInfoHandler.Handle,
+ }
+
+ router := server.NewRouter(cfg, authenticator, oidcValidator, handlers)
+
+ srv := server.New(cfg.Server, router, logger)
+ if err := srv.ListenAndServe(); err != nil {
+ logger.Error("server error", "error", err)
+ os.Exit(1)
+ }
+}
+
+func setupLogger(cfg config.LoggingConfig) *slog.Logger {
+ var level slog.Level
+ switch strings.ToLower(cfg.Level) {
+ case "debug":
+ level = slog.LevelDebug
+ case "warn":
+ level = slog.LevelWarn
+ case "error":
+ level = slog.LevelError
+ default:
+ level = slog.LevelInfo
+ }
+
+ opts := &slog.HandlerOptions{Level: level}
+
+ var h slog.Handler
+ if strings.ToLower(cfg.Format) == "text" {
+ h = slog.NewTextHandler(os.Stdout, opts)
+ } else {
+ h = slog.NewJSONHandler(os.Stdout, opts)
+ }
+
+ return slog.New(h)
+}
diff --git a/signer/migrations/001_initial_schema.up.sql b/signer/migrations/001_initial_schema.up.sql
new file mode 100644
index 000000000..1fd2f2211
--- /dev/null
+++ b/signer/migrations/001_initial_schema.up.sql
@@ -0,0 +1,79 @@
+SET NAMES utf8mb4;
+SET time_zone = '+00:00';
+
+CREATE TABLE IF NOT EXISTS client_ssh_configs
+(
+ tenant_id VARCHAR(255) NOT NULL,
+ client_id VARCHAR(255) NOT NULL,
+ client_secret VARCHAR(512) NOT NULL,
+ target_host VARCHAR(255) NOT NULL,
+ target_port INT NOT NULL DEFAULT 22,
+ max_ttl_seconds INT NOT NULL DEFAULT 86400,
+ allowed_key_types JSON NOT NULL,
+ source_address_restriction VARCHAR(255) NULL,
+ critical_options JSON NULL,
+ extensions JSON NULL,
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
+ ON UPDATE CURRENT_TIMESTAMP(6),
+
+ PRIMARY KEY (tenant_id, client_id),
+ INDEX idx_client_configs_enabled (enabled),
+ INDEX idx_client_configs_target_host (target_host)
+) ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS certificate_issuance_logs
+(
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ tenant_id VARCHAR(255) NOT NULL,
+ client_id VARCHAR(255) NOT NULL,
+ serial_number BIGINT NOT NULL,
+ key_id VARCHAR(255) NOT NULL,
+ principal VARCHAR(255) NOT NULL,
+ user_email VARCHAR(255) NULL,
+ public_key_fingerprint VARCHAR(255) NOT NULL,
+ ca_fingerprint VARCHAR(255) NOT NULL,
+ valid_after TIMESTAMP(6) NOT NULL,
+ valid_before TIMESTAMP(6) NOT NULL,
+ issued_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ source_ip VARCHAR(45) NULL,
+ user_access_token_hash VARCHAR(255) NULL,
+ request_metadata JSON NULL,
+
+ PRIMARY KEY (id),
+ UNIQUE KEY uq_issuance_serial (serial_number),
+ INDEX idx_issuance_tenant_client (tenant_id, client_id),
+ INDEX idx_issuance_keyid (key_id),
+ INDEX idx_issuance_principal (principal),
+ INDEX idx_issuance_user_email (user_email),
+ INDEX idx_issuance_ca_fingerprint (ca_fingerprint),
+ INDEX idx_issuance_issued_at (issued_at),
+ INDEX idx_issuance_valid_range (valid_after, valid_before)
+) ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS revocation_events
+(
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ tenant_id VARCHAR(255) NOT NULL,
+ client_id VARCHAR(255) NOT NULL,
+ serial_number BIGINT NULL,
+ key_id VARCHAR(255) NULL,
+ ca_fingerprint VARCHAR(255) NULL,
+ revoked_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ reason VARCHAR(255) NOT NULL,
+ revoked_by VARCHAR(255) NOT NULL,
+
+ PRIMARY KEY (id),
+ INDEX idx_revocation_tenant_client (tenant_id, client_id),
+ INDEX idx_revocation_serial (serial_number),
+ INDEX idx_revocation_keyid (key_id),
+ INDEX idx_revocation_ca_fingerprint (ca_fingerprint),
+ INDEX idx_revocation_revoked_at (revoked_at)
+) ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_unicode_ci;