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:38 UTC
(airavata-custos) branch signer-service created (now 5cb1298ae)
This is an automated email from the ASF dual-hosted git repository.
lahirujayathilake pushed a change to branch signer-service
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
at 5cb1298ae initial signer service implementation
This branch includes the following new commits:
new 5cb1298ae initial signer service implementation
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(airavata-custos) 01/01: initial signer service implementation
Posted by la...@apache.org.
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;