ops: add CI/CD pipeline, a/b rolling deploy, Gitea Actions workflow
Deploy to Production / deploy (push) Failing after 10s

- .gitea/workflows/deploy.yml — push-to-main triggers rolling deploy
- scripts/deploy-bluegreen.sh — a-stack then b-stack restart; Maven runs
  in Docker (no JDK needed on runner host); Caddy reload at end
- scripts/deploy-all.ps1 — emergency manual deploy from dev machine
- infra/docker-compose.yml — a/b pairs per service; wget health checks;
  Gitea service; Prometheus/Grafana/DB ports restricted to localhost
- infra/Caddyfile — dual upstreams with health-based routing
- infra/Dockerfile.* — one per service
- infra/prometheus.yml + grafana provisioning

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Carsten Rehfeld
2026-05-14 14:01:12 +02:00
parent 5156089152
commit 82f0ac6007
72 changed files with 4715 additions and 27 deletions
@@ -0,0 +1,26 @@
package org.botstandards.apix.registry.dto;
import java.util.List;
public record MailSigningKeyResponse(
String issuer,
SigningInfo signingInfo,
List<KeyInfo> keys) {
public record SigningInfo(
String algorithm,
String curve,
String javaAlgorithmId,
String canonicalization,
String signatureEncoding,
List<String> verificationSteps,
String javaKeyReconstruction) {}
public record KeyInfo(
String kid,
String kty,
String crv,
String x,
String use,
String publicKeySpkiBase64) {}
}
@@ -0,0 +1,37 @@
package org.botstandards.apix.registry.dto;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.common.OrgEventType;
import org.botstandards.apix.registry.entity.OrgVerificationEventEntity;
import org.botstandards.apix.registry.service.MailSigningService;
import java.time.Instant;
import java.util.UUID;
public record OrgAuditEventResponse(
UUID id,
OrgEventType eventType,
OLevel fromLevel,
OLevel toLevel,
String triggeredBy,
String notes,
Instant createdAt,
String kid,
String signature) {
public static OrgAuditEventResponse from(OrgVerificationEventEntity ev, UUID orgId,
MailSigningService signer) {
String sig = signer.signAuditEvent(
ev.id, orgId,
ev.eventType.name(),
ev.fromLevel != null ? ev.fromLevel.name() : null,
ev.toLevel != null ? ev.toLevel.name() : null,
ev.triggeredBy,
ev.notes,
ev.createdAt.toString());
return new OrgAuditEventResponse(
ev.id, ev.eventType, ev.fromLevel, ev.toLevel,
ev.triggeredBy, ev.notes, ev.createdAt,
signer.getKid(), sig);
}
}
@@ -0,0 +1,11 @@
package org.botstandards.apix.registry.dto;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record OrgEventFeedResponse(
UUID orgId,
Instant generatedAt,
String kid,
List<OrgAuditEventResponse> events) {}
@@ -0,0 +1,11 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrgFraudReportRequest(
@NotBlank @Email String email,
String notes
) {}
@@ -0,0 +1,13 @@
package org.botstandards.apix.registry.dto;
import java.util.Map;
public record OrgKeyRotateResponse(
String apiKey,
String rotationSecret,
String warning,
Map<String, Object> signedNotification) {
public static final String KEY_WARNING =
"New credentials shown exactly once. Store securely — they cannot be retrieved again.";
}
@@ -0,0 +1,17 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.botstandards.apix.common.OLevel;
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrgRegistrationRequest(
@NotBlank String registrantName,
@NotBlank @Email String registrantEmail,
@NotBlank String registrantJurisdiction,
@NotBlank String registrantOrgType,
@NotBlank String domain,
@NotNull OLevel targetOLevel
) {}
@@ -0,0 +1,25 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.common.VerificationStatus;
import java.util.UUID;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record OrgRegistrationResponse(
UUID id,
String apiKey,
String rotationSecret,
OLevel targetOLevel,
OLevel earnedOLevel,
VerificationStatus verificationStatus,
// Only present for targetOLevel >= IDENTITY_VERIFIED — org must publish this as DNS TXT record
String dnsVerificationToken,
String warning
) {
public static final String KEY_WARNING =
"Both credentials are shown exactly once. " +
"The rotationSecret is your emergency key — treat it as more sensitive than the apiKey. " +
"Exposure of the rotationSecret bypasses self-service recovery.";
}
@@ -0,0 +1,46 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.common.VerificationStatus;
import org.botstandards.apix.registry.entity.OrganizationEntity;
import java.time.Instant;
import java.util.UUID;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record OrgResponse(
UUID id,
String registrantName,
String registrantEmail,
String registrantJurisdiction,
String registrantOrgType,
String domain,
String lei,
OLevel targetOLevel,
OLevel earnedOLevel,
OLevel effectiveOLevel,
VerificationStatus verificationStatus,
String verificationStep,
String verificationError,
String dnsVerificationToken,
OLevel tempMaxOLevel,
Instant tempMaxExpiresAt,
boolean fraudLocked,
Instant fraudLockedAt,
Instant createdAt,
Instant updatedAt
) {
public static OrgResponse from(OrganizationEntity e, Instant now) {
return new OrgResponse(
e.id, e.registrantName, e.registrantEmail, e.registrantJurisdiction,
e.registrantOrgType, e.domain, e.lei,
e.targetOLevel, e.earnedOLevel, e.effectiveOLevel(now),
e.verificationStatus, e.verificationStep, e.verificationError,
e.dnsVerificationToken,
e.tempMaxOLevel, e.tempMaxExpiresAt,
e.fraudLocked, e.fraudLockedAt,
e.createdAt, e.updatedAt
);
}
}
@@ -0,0 +1,8 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrgTanRequestBody(@NotBlank @Email String email) {}
@@ -0,0 +1,7 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.validation.constraints.NotBlank;
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrgTanRotateRequest(@NotBlank String tan) {}
@@ -0,0 +1,16 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.botstandards.apix.common.OLevel;
import java.time.Instant;
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrgTempGrantRequest(
@NotNull OLevel tempMaxOLevel,
@NotNull Instant expiresAt,
@NotBlank String grantedBy,
String reason
) {}
@@ -0,0 +1,8 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.validation.constraints.NotNull;
import org.botstandards.apix.common.OLevel;
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrgUpgradeRequest(@NotNull OLevel targetOLevel) {}
@@ -0,0 +1,42 @@
package org.botstandards.apix.registry.entity;
import jakarta.persistence.*;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.common.OrgEventType;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "org_verification_events")
public class OrgVerificationEventEntity {
@Id
@Column(columnDefinition = "uuid")
public UUID id;
@Column(name = "organization_id", nullable = false, columnDefinition = "uuid")
public UUID organizationId;
@Enumerated(EnumType.STRING)
@Column(name = "event_type", nullable = false, length = 50)
public OrgEventType eventType;
@Enumerated(EnumType.STRING)
@Column(name = "from_level", length = 50)
public OLevel fromLevel;
@Enumerated(EnumType.STRING)
@Column(name = "to_level", length = 50)
public OLevel toLevel;
// SYSTEM | OWNER | BSF_ADMIN
@Column(name = "triggered_by", length = 50)
public String triggeredBy;
@Column(name = "notes", columnDefinition = "text")
public String notes;
@Column(name = "created_at", nullable = false)
public Instant createdAt;
}
@@ -0,0 +1,117 @@
package org.botstandards.apix.registry.entity;
import jakarta.persistence.*;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.common.VerificationStatus;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "organizations")
public class OrganizationEntity {
@Id
@Column(columnDefinition = "uuid")
public UUID id;
@Column(name = "registrant_name", nullable = false, length = 255)
public String registrantName;
@Column(name = "registrant_email", nullable = false, length = 255)
public String registrantEmail;
@Column(name = "registrant_jurisdiction", nullable = false, length = 10)
public String registrantJurisdiction;
@Column(name = "registrant_org_type", nullable = false, length = 50)
public String registrantOrgType;
@Column(name = "domain", nullable = false, length = 255)
public String domain;
@Column(name = "lei", length = 20)
public String lei;
@Column(name = "api_key_hash", nullable = false, length = 64, unique = true)
public String apiKeyHash;
@Column(name = "rotation_secret_hash", nullable = false, length = 64, unique = true)
public String rotationSecretHash;
@Enumerated(EnumType.STRING)
@Column(name = "target_o_level", nullable = false, length = 50)
public OLevel targetOLevel;
@Enumerated(EnumType.STRING)
@Column(name = "earned_o_level", nullable = false, length = 50)
public OLevel earnedOLevel = OLevel.UNVERIFIED;
@Enumerated(EnumType.STRING)
@Column(name = "verification_status", nullable = false, length = 50)
public VerificationStatus verificationStatus = VerificationStatus.PENDING;
@Column(name = "verification_step", length = 100)
public String verificationStep;
@Column(name = "verification_error", columnDefinition = "text")
public String verificationError;
@Column(name = "dns_verification_token", length = 64)
public String dnsVerificationToken;
// BSF temporary grant
@Enumerated(EnumType.STRING)
@Column(name = "temp_max_o_level", length = 50)
public OLevel tempMaxOLevel;
@Column(name = "temp_max_expires_at")
public Instant tempMaxExpiresAt;
@Column(name = "temp_max_granted_by", length = 255)
public String tempMaxGrantedBy;
@Column(name = "temp_max_granted_reason", columnDefinition = "text")
public String tempMaxGrantedReason;
// Emergency key rotation TAN
@Column(name = "pending_tan_hash", length = 64)
public String pendingTanHash;
@Column(name = "pending_tan_expires_at")
public Instant pendingTanExpires;
@Column(name = "tan_request_count_24h", nullable = false)
public int tanRequestCount24h = 0;
@Column(name = "tan_last_requested_at")
public Instant tanLastRequestedAt;
// DNS-challenge key rotation (bot-friendly, analogous to ACME DNS-01)
// Token is stored plaintext — it is a public DNS value, intentionally not secret.
@Column(name = "pending_rotation_challenge_token", length = 64)
public String pendingRotationChallengeToken;
@Column(name = "pending_rotation_challenge_expires_at")
public Instant pendingRotationChallengeExpires;
// Fraud lock: set by reportFraud(); blocks all key-rotation attempts until cleared by BSF admin.
@Column(name = "fraud_locked", nullable = false)
public boolean fraudLocked = false;
@Column(name = "fraud_locked_at")
public Instant fraudLockedAt;
@Column(name = "created_at", nullable = false)
public Instant createdAt;
@Column(name = "updated_at", nullable = false)
public Instant updatedAt;
public OLevel effectiveOLevel(Instant now) {
if (tempMaxOLevel != null && tempMaxExpiresAt != null && now.isBefore(tempMaxExpiresAt)) {
return tempMaxOLevel.ordinal() > earnedOLevel.ordinal() ? tempMaxOLevel : earnedOLevel;
}
return earnedOLevel;
}
}
@@ -0,0 +1,29 @@
package org.botstandards.apix.registry.filter;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.container.ContainerResponseContext;
import jakarta.ws.rs.container.ContainerResponseFilter;
import jakarta.ws.rs.ext.Provider;
import java.io.IOException;
@Provider
public class ServerTimingFilter implements ContainerRequestFilter, ContainerResponseFilter {
private static final String START_NS = "apix.start.ns";
@Override
public void filter(ContainerRequestContext req) throws IOException {
req.setProperty(START_NS, System.nanoTime());
}
@Override
public void filter(ContainerRequestContext req, ContainerResponseContext resp) throws IOException {
Object start = req.getProperty(START_NS);
if (start instanceof Long startNs) {
double ms = (System.nanoTime() - startNs) / 1_000_000.0;
resp.getHeaders().add("Server-Timing", String.format("app;dur=%.1f", ms));
}
}
}
@@ -0,0 +1,63 @@
package org.botstandards.apix.registry.resource;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import org.botstandards.apix.registry.dto.MailSigningKeyResponse;
import org.botstandards.apix.registry.service.MailSigningService;
import java.util.List;
/**
* Public endpoint exposing the APIX mail signing public key and verification instructions.
*
* Recipients of APIX notification emails can fetch the public key identified by the 'kid'
* field in the email's signed payload and verify the Ed25519 signature to confirm the
* message is authentic and unchanged.
*
* No authentication required — this endpoint is intentionally public.
*/
@Path("/mail-signing-keys")
@Produces(MediaType.APPLICATION_JSON)
public class MailSigningKeyResource {
@Inject MailSigningService signingService;
@GET
public MailSigningKeyResponse getKeys() {
return new MailSigningKeyResponse(
"apix-registry",
new MailSigningKeyResponse.SigningInfo(
"EdDSA",
"Ed25519",
"Ed25519",
"Fields sorted alphabetically by key name, null-valued fields excluded, " +
"compact JSON (no whitespace), UTF-8 encoded. " +
"The 'signature' and 'kid' fields are excluded from the canonical form before signing.",
"Base64URL without padding (RFC 4648 §5)",
List.of(
"1. Parse the signed JSON payload from the email body (delimited by ---APIX-SIGNED-PAYLOAD---).",
"2. Extract the 'kid' value. Fetch the corresponding key from GET /mail-signing-keys.",
"3. Remove the 'signature' and 'kid' fields from the parsed object.",
"4. Remove any fields with null values.",
"5. Sort the remaining fields alphabetically by key name.",
"6. Serialize to compact JSON (no whitespace) encoded as UTF-8 bytes.",
"7. Decode the 'signature' string from Base64URL.",
"8. Verify the Ed25519 signature over the UTF-8 bytes using the public key."
),
"byte[] spki = Base64.getDecoder().decode(publicKeySpkiBase64); " +
"PublicKey pk = KeyFactory.getInstance(\"Ed25519\").generatePublic(new X509EncodedKeySpec(spki)); " +
"Signature sig = Signature.getInstance(\"Ed25519\"); " +
"sig.initVerify(pk); sig.update(canonicalBytes); boolean valid = sig.verify(signatureBytes);"
),
List.of(new MailSigningKeyResponse.KeyInfo(
signingService.getKid(),
"OKP",
"Ed25519",
signingService.getPublicKeyX(),
"sig",
signingService.getPublicKeySpki()
))
);
}
}
@@ -0,0 +1,197 @@
package org.botstandards.apix.registry.resource;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.registry.dto.*;
import org.botstandards.apix.registry.service.OrganizationService;
import java.net.URI;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Path("/organizations")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class OrganizationResource {
@Inject OrganizationService orgService;
// ── Public registration ───────────────────────────────────────────────────
@POST
public Response register(@Valid OrgRegistrationRequest req) {
OrgRegistrationResponse resp = orgService.register(req);
return Response.created(URI.create("/organizations/" + resp.id()))
.entity(resp)
.build();
}
// ── Read ──────────────────────────────────────────────────────────────────
@GET
@Path("/{id}")
public OrgResponse getById(@PathParam("id") UUID id) {
return orgService.getById(id);
}
// ── Audit log ─────────────────────────────────────────────────────────────
// Accessible to the org owner (X-Org-Api-Key) or BSF admin (X-Api-Key).
// Events are returned newest-first; suitable for BSF yearly reporting and
// owner-facing transparency view.
@GET
@Path("/{id}/audit-log")
public List<OrgAuditEventResponse> getAuditLog(
@PathParam("id") UUID id,
@HeaderParam("X-Org-Api-Key") String apiKey,
@HeaderParam("X-Api-Key") String adminKey) {
return orgService.getAuditLog(id, apiKey, adminKey);
}
// ── Event feed — signed, chronological, agent and BSF reporting friendly ──
// Oldest-first; optional ?since=<ISO8601> for incremental polling.
// Every event carries an Ed25519 signature verifiable against GET /mail-signing-keys.
@GET
@Path("/{id}/event-feed")
public OrgEventFeedResponse getEventFeed(
@PathParam("id") UUID id,
@HeaderParam("X-Org-Api-Key") String apiKey,
@HeaderParam("X-Api-Key") String adminKey,
@QueryParam("since") String sinceParam) {
Instant since = sinceParam != null && !sinceParam.isBlank()
? Instant.parse(sinceParam) : null;
return orgService.getEventFeed(id, apiKey, adminKey, since);
}
// ── Owner: trigger verification ───────────────────────────────────────────
@POST
@Path("/{id}/verify")
public OrgResponse verify(@PathParam("id") UUID id,
@HeaderParam("X-Org-Api-Key") String key) {
return orgService.verify(id, key);
}
// ── Owner: request upgrade to a higher level ──────────────────────────────
@POST
@Path("/{id}/request-upgrade")
public OrgResponse requestUpgrade(@PathParam("id") UUID id,
@Valid OrgUpgradeRequest req,
@HeaderParam("X-Org-Api-Key") String key) {
return orgService.requestUpgrade(id, req, key);
}
// ── Admin: assign earned level manually (O-4+) ────────────────────────────
@PATCH
@Path("/{id}/earned-level")
public OrgResponse assignEarnedLevel(@PathParam("id") UUID id,
Map<String, String> body,
@HeaderParam("X-Api-Key") String adminKey) {
String levelStr = body.get("earnedOLevel");
if (levelStr == null || levelStr.isBlank()) {
throw new BadRequestException("earnedOLevel is required");
}
OLevel level;
try {
level = OLevel.valueOf(levelStr);
} catch (IllegalArgumentException e) {
throw new BadRequestException("unknown OLevel: " + levelStr);
}
return orgService.assignEarnedLevel(id, level, adminKey);
}
// ── Admin: grant temporary elevated level ─────────────────────────────────
@POST
@Path("/{id}/temp-grant")
public OrgResponse grantTemp(@PathParam("id") UUID id,
@Valid OrgTempGrantRequest req,
@HeaderParam("X-Api-Key") String adminKey) {
return orgService.grantTemp(id, req, adminKey);
}
// ── Admin: revoke temporary grant ─────────────────────────────────────────
@DELETE
@Path("/{id}/temp-grant")
public OrgResponse revokeTemp(@PathParam("id") UUID id,
@HeaderParam("X-Api-Key") String adminKey) {
return orgService.revokeTemp(id, adminKey);
}
// ── Owner: self-service key rotation (step 1 — issues 2FA TAN to registered email) ──
@POST
@Path("/{id}/rotate-key")
public Response rotateKey(@PathParam("id") UUID id,
@HeaderParam("X-Org-Rotation-Secret") String rotationSecret) {
return Response.ok(orgService.rotateKey(id, rotationSecret)).build();
}
// ── Emergency: request TAN ───────────────────────────────────────────────
@POST
@Path("/{id}/request-tan")
public Response requestTan(@PathParam("id") UUID id,
@Valid OrgTanRequestBody body) {
return Response.ok(orgService.requestTan(id, body)).build();
}
// ── DNS-challenge key rotation (bot-friendly) ─────────────────────────────
// Step 1: rotation secret → challenge token (agent publishes to DNS)
// Step 2: rotation secret again → DoH verifies DNS, new keys issued
// No email or inbox access required — machine-native ACME DNS-01 pattern.
@POST
@Path("/{id}/rotate-key-dns")
public Response initiateKeyRotationDns(@PathParam("id") UUID id,
@HeaderParam("X-Org-Rotation-Secret") String rotationSecret) {
return Response.ok(orgService.initiateKeyRotationDns(id, rotationSecret)).build();
}
@POST
@Path("/{id}/confirm-key-rotation-dns")
public OrgKeyRotateResponse confirmKeyRotationDns(@PathParam("id") UUID id,
@HeaderParam("X-Org-Rotation-Secret") String rotationSecret) {
return orgService.confirmKeyRotationDns(id, rotationSecret);
}
// ── Key rotation step 2 / emergency: complete rotation using TAN ─────────
// Shared completion endpoint for both self-service 2FA (rotation secret → TAN)
// and emergency recovery (email → TAN). No API key required — TAN is the auth.
@POST
@Path("/{id}/rotate-key-with-tan")
public OrgKeyRotateResponse rotateKeyWithTan(@PathParam("id") UUID id,
@Valid OrgTanRotateRequest body) {
return orgService.rotateKeyWithTan(id, body);
}
// ── Admin: clear fraud lock after investigation ───────────────────────────
@DELETE
@Path("/{id}/fraud-lock")
public OrgResponse clearFraudLock(@PathParam("id") UUID id,
@HeaderParam("X-Api-Key") String adminKey) {
return orgService.clearFraudLock(id, adminKey);
}
// ── Fraud report — no auth required ──────────────────────────────────────
// Called by a legitimate owner who received an unsolicited key rotation warning.
// Always returns a generic confirmation to prevent org-existence enumeration.
@POST
@Path("/{id}/notify-key-rotation-fraud")
public Response notifyKeyRotationFraud(@PathParam("id") UUID id,
@Valid OrgFraudReportRequest req) {
return Response.ok(orgService.reportFraud(id, req)).build();
}
}
@@ -0,0 +1,159 @@
package org.botstandards.apix.registry.service;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.*;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Ed25519 signing service for all outbound APIX notifications and audit events.
*
* Canonical form for signing: fields sorted alphabetically by key, null-valued
* fields excluded, compact JSON (no whitespace), UTF-8 encoded. The 'signature'
* and 'kid' fields are always excluded from the payload before signing.
*
* In dev mode (no key configured) an ephemeral key pair is generated at startup.
* Set APIX_MAIL_SIGNING_PRIVATE_KEY and APIX_MAIL_SIGNING_PUBLIC_KEY (PKCS#8 /
* SubjectPublicKeyInfo, Base64 standard encoding) for production.
*/
@ApplicationScoped
public class MailSigningService {
private static final Logger log = Logger.getLogger(MailSigningService.class);
@ConfigProperty(name = "apix.mail.signing.private-key-base64")
Optional<String> privateKeyBase64;
@ConfigProperty(name = "apix.mail.signing.public-key-base64")
Optional<String> publicKeyBase64;
@ConfigProperty(name = "apix.mail.signing.kid", defaultValue = "dev")
String kid;
private PrivateKey privateKey;
private PublicKey publicKey;
@PostConstruct
void init() {
try {
if (privateKeyBase64.isEmpty() || publicKeyBase64.isEmpty()) {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519");
KeyPair kp = kpg.generateKeyPair();
this.privateKey = kp.getPrivate();
this.publicKey = kp.getPublic();
log.warn("Mail signing: ephemeral Ed25519 key pair (dev/test mode). " +
"Set APIX_MAIL_SIGNING_PRIVATE_KEY and APIX_MAIL_SIGNING_PUBLIC_KEY for production.");
} else {
KeyFactory kf = KeyFactory.getInstance("Ed25519");
this.privateKey = kf.generatePrivate(
new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKeyBase64.get())));
this.publicKey = kf.generatePublic(
new X509EncodedKeySpec(Base64.getDecoder().decode(publicKeyBase64.get())));
}
} catch (Exception e) {
throw new IllegalStateException("Mail signing key initialisation failed", e);
}
}
public String getKid() {
return kid;
}
/**
* Returns the raw 32-byte Ed25519 public key as Base64URL (JWK OKP 'x' field, RFC 8037).
* The SubjectPublicKeyInfo encoding is always a fixed 12-byte header followed by the raw key.
*/
public String getPublicKeyX() {
byte[] spki = publicKey.getEncoded();
byte[] raw = Arrays.copyOfRange(spki, spki.length - 32, spki.length);
return Base64.getUrlEncoder().withoutPadding().encodeToString(raw);
}
/**
* Returns the full SubjectPublicKeyInfo (SPKI) as Base64 standard encoding.
* Java consumers: KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(value)))
*/
public String getPublicKeySpki() {
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
}
/**
* Signs a flat string map. Null values are excluded from the canonical form.
* Returns the Base64URL-encoded Ed25519 signature.
*/
public String sign(Map<String, String> fields) {
String canonical = buildCanonical(fields);
try {
Signature sig = Signature.getInstance("Ed25519");
sig.initSign(privateKey);
sig.update(canonical.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(sig.sign());
} catch (Exception e) {
throw new IllegalStateException("Signing failed", e);
}
}
/** Builds the signed notification payload for a rotation or key lifecycle event. */
public Map<String, Object> signedNotification(UUID orgId, String eventType,
String timestamp, String message) {
Map<String, String> fields = new TreeMap<>();
fields.put("eventType", eventType);
fields.put("issuer", "apix-registry");
fields.put("message", message);
fields.put("orgId", orgId.toString());
fields.put("timestamp", timestamp);
String signature = sign(fields);
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("kid", kid);
payload.put("issuer", "apix-registry");
payload.put("orgId", orgId.toString());
payload.put("eventType", eventType);
payload.put("timestamp", timestamp);
payload.put("message", message);
payload.put("signature", signature);
return payload;
}
/** Builds the canonical JSON for an audit event (non-null fields only, sorted). */
public String signAuditEvent(UUID eventId, UUID orgId, String eventType,
String fromLevel, String toLevel, String triggeredBy,
String notes, String createdAt) {
Map<String, String> fields = new TreeMap<>();
fields.put("createdAt", createdAt);
fields.put("eventType", eventType);
if (fromLevel != null) fields.put("fromLevel", fromLevel);
fields.put("id", eventId.toString());
if (notes != null) fields.put("notes", notes);
fields.put("orgId", orgId.toString());
if (toLevel != null) fields.put("toLevel", toLevel);
fields.put("triggeredBy", triggeredBy);
return sign(fields);
}
// Canonical form: fields sorted alphabetically, null values excluded, compact JSON, UTF-8
static String buildCanonical(Map<String, String> fields) {
return fields.entrySet().stream()
.filter(e -> e.getValue() != null)
.sorted(Map.Entry.comparingByKey())
.map(e -> "\"" + jsonEscape(e.getKey()) + "\":\"" + jsonEscape(e.getValue()) + "\"")
.collect(Collectors.joining(",", "{", "}"));
}
private static String jsonEscape(String s) {
return s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
}
}
@@ -0,0 +1,649 @@
package org.botstandards.apix.registry.service;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import org.botstandards.apix.common.*;
import org.botstandards.apix.registry.dto.*;
import org.botstandards.apix.registry.entity.OrgVerificationEventEntity;
import org.botstandards.apix.registry.entity.OrganizationEntity;
import org.botstandards.apix.verification.RotationChallengeVerifier;
import org.botstandards.apix.verification.VerificationConfig;
import org.botstandards.apix.verification.VerificationPipeline;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class OrganizationService {
@Inject EntityManager em;
@Inject ClockService clock;
@Inject MailSigningService mailSigner;
@ConfigProperty(name = "apix.api-key") String adminApiKey;
@ConfigProperty(name = "apix.dns.doh-url") String dohUrl;
@ConfigProperty(name = "apix.gleif.api-url") String gleifApiUrl;
@ConfigProperty(name = "apix.opencorporates.api-key") Optional<String> ocApiKey;
@ConfigProperty(name = "apix.opencorporates.api-url", defaultValue = "https://api.opencorporates.com/v0.4") String ocApiUrl;
@ConfigProperty(name = "apix.hygiene.security-txt-url-template",
defaultValue = "https://{domain}/.well-known/security.txt") String securityTxtTemplate;
@ConfigProperty(name = "apix.verification.http-timeout-ms", defaultValue = "5000") long httpTimeoutMs;
@ConfigProperty(name = "apix.org.tan.expose-in-response", defaultValue = "false") boolean exposeTanInResponse;
// ── Public registration ───────────────────────────────────────────────────
@Transactional
public OrgRegistrationResponse register(OrgRegistrationRequest req) {
Instant now = clock.now();
String apiKey = generateKey("apix_org_");
String rotationSecret = generateKey("apix_rot_");
String dnsToken = req.targetOLevel().ordinal() >= OLevel.IDENTITY_VERIFIED.ordinal()
? generateToken() : null;
OrganizationEntity org = new OrganizationEntity();
org.id = UUID.randomUUID();
org.registrantName = req.registrantName();
org.registrantEmail = req.registrantEmail();
org.registrantJurisdiction = req.registrantJurisdiction();
org.registrantOrgType = req.registrantOrgType();
org.domain = req.domain();
org.apiKeyHash = hash(apiKey);
org.rotationSecretHash = hash(rotationSecret);
org.targetOLevel = req.targetOLevel();
org.earnedOLevel = OLevel.UNVERIFIED;
org.dnsVerificationToken = dnsToken;
org.createdAt = now;
org.updatedAt = now;
if (req.targetOLevel() == OLevel.UNVERIFIED) {
org.verificationStatus = VerificationStatus.ACHIEVED;
} else if (req.targetOLevel().ordinal() >= OLevel.OPERATIONALLY_VERIFIED.ordinal()) {
org.verificationStatus = VerificationStatus.MANUAL_REVIEW;
} else {
org.verificationStatus = VerificationStatus.PENDING;
}
em.persist(org);
recordEvent(org.id, OrgEventType.REGISTERED, null, null, "SYSTEM", null, now);
return new OrgRegistrationResponse(
org.id, apiKey, rotationSecret,
org.targetOLevel, org.earnedOLevel, org.verificationStatus,
dnsToken, OrgRegistrationResponse.KEY_WARNING);
}
// ── Read ──────────────────────────────────────────────────────────────────
public OrgResponse getById(UUID id) {
return OrgResponse.from(requireOrg(id), clock.now());
}
// ── Audit log — accessible to org owner (api key) or BSF admin ────────────
// Returns all events for the org, newest first.
// Either credential is sufficient; the caller does not learn which one was accepted.
public List<OrgAuditEventResponse> getAuditLog(UUID id, String apiKeyHeader, String adminKeyHeader) {
OrganizationEntity org = requireAuthorized(id, apiKeyHeader, adminKeyHeader);
return em.createQuery(
"SELECT e FROM OrgVerificationEventEntity e " +
"WHERE e.organizationId = :orgId ORDER BY e.createdAt DESC",
OrgVerificationEventEntity.class)
.setParameter("orgId", id)
.getResultList()
.stream()
.map(ev -> OrgAuditEventResponse.from(ev, id, mailSigner))
.toList();
}
// ── Event feed — signed, chronological, agent-friendly ───────────────────
// Oldest-first ordering suits sequential consumption by agents and BSF tooling.
// Optional 'since' filter enables incremental polling without re-fetching known events.
// Each event carries an Ed25519 signature allowing receivers to verify integrity
// and APIX authorship. The 'kid' in the response identifies the signing key.
public OrgEventFeedResponse getEventFeed(UUID id, String apiKeyHeader, String adminKeyHeader,
Instant since) {
requireAuthorized(id, apiKeyHeader, adminKeyHeader);
Instant now = clock.now();
String baseQuery = "SELECT e FROM OrgVerificationEventEntity e " +
"WHERE e.organizationId = :orgId";
if (since != null) baseQuery += " AND e.createdAt > :since";
baseQuery += " ORDER BY e.createdAt ASC";
var query = em.createQuery(baseQuery, OrgVerificationEventEntity.class)
.setParameter("orgId", id);
if (since != null) query.setParameter("since", since);
List<OrgAuditEventResponse> events = query.getResultList()
.stream()
.map(ev -> OrgAuditEventResponse.from(ev, id, mailSigner))
.toList();
return new OrgEventFeedResponse(id, now, mailSigner.getKid(), events);
}
private OrganizationEntity requireAuthorized(UUID id, String apiKeyHeader, String adminKeyHeader) {
OrganizationEntity org = requireOrg(id);
boolean isOwner = apiKeyHeader != null && hash(apiKeyHeader).equals(org.apiKeyHash);
boolean isAdmin = adminApiKey.equals(adminKeyHeader);
if (!isOwner && !isAdmin) {
throw new ForbiddenException("valid X-Org-Api-Key or X-Api-Key required");
}
return org;
}
// ── Owner: trigger verification ───────────────────────────────────────────
@Transactional
public OrgResponse verify(UUID id, String apiKeyHeader) {
Instant now = clock.now();
OrganizationEntity org = requireOrg(id);
requireOrgKey(org, apiKeyHeader);
if (org.verificationStatus == VerificationStatus.ACHIEVED
|| org.verificationStatus == VerificationStatus.MANUAL_REVIEW) {
return OrgResponse.from(org, now);
}
VerificationResult result = pipeline().run(
org.targetOLevel, org.domain, org.dnsVerificationToken,
org.registrantName, org.registrantJurisdiction);
applyVerificationResult(org, result, now);
org.updatedAt = now;
return OrgResponse.from(org, now);
}
// ── Owner: request upgrade to a higher level ──────────────────────────────
@Transactional
public OrgResponse requestUpgrade(UUID id, OrgUpgradeRequest req, String apiKeyHeader) {
Instant now = clock.now();
OrganizationEntity org = requireOrg(id);
requireOrgKey(org, apiKeyHeader);
if (req.targetOLevel().ordinal() <= org.earnedOLevel.ordinal()) {
throw unprocessable("targetOLevel must be higher than current earnedOLevel");
}
OLevel previousTarget = org.targetOLevel;
org.targetOLevel = req.targetOLevel();
org.verificationStatus = req.targetOLevel().ordinal() >= OLevel.OPERATIONALLY_VERIFIED.ordinal()
? VerificationStatus.MANUAL_REVIEW : VerificationStatus.PENDING;
org.verificationStep = null;
org.verificationError = null;
// Regenerate DNS token for new target if DNS-requiring level
if (req.targetOLevel().ordinal() >= OLevel.IDENTITY_VERIFIED.ordinal()
&& org.dnsVerificationToken == null) {
org.dnsVerificationToken = generateToken();
}
org.updatedAt = now;
recordEvent(org.id, OrgEventType.UPGRADE_REQUESTED, previousTarget, req.targetOLevel(),
"OWNER", "upgrade requested to " + req.targetOLevel(), now);
return OrgResponse.from(org, now);
}
// ── Admin: assign earned level manually (O-4+) ────────────────────────────
@Transactional
public OrgResponse assignEarnedLevel(UUID id, OLevel level, String adminKeyHeader) {
Instant now = clock.now();
requireAdminKey(adminKeyHeader);
OrganizationEntity org = requireOrg(id);
OLevel previous = org.earnedOLevel;
org.earnedOLevel = level;
org.verificationStatus = VerificationStatus.ACHIEVED;
org.verificationStep = null;
org.verificationError = null;
org.updatedAt = now;
recordEvent(org.id, OrgEventType.LEVEL_EARNED, previous, level, "BSF_ADMIN", "manual assignment", now);
return OrgResponse.from(org, now);
}
// ── Admin: grant temporary elevated level ─────────────────────────────────
@Transactional
public OrgResponse grantTemp(UUID id, OrgTempGrantRequest req, String adminKeyHeader) {
Instant now = clock.now();
requireAdminKey(adminKeyHeader);
if (req.expiresAt().isBefore(now)) {
throw unprocessable("expiresAt must be in the future");
}
OrganizationEntity org = requireOrg(id);
org.tempMaxOLevel = req.tempMaxOLevel();
org.tempMaxExpiresAt = req.expiresAt();
org.tempMaxGrantedBy = req.grantedBy();
org.tempMaxGrantedReason = req.reason();
org.updatedAt = now;
recordEvent(org.id, OrgEventType.TEMP_GRANTED, org.earnedOLevel, req.tempMaxOLevel(),
"BSF_ADMIN", "expires " + req.expiresAt() + "; reason: " + req.reason(), now);
return OrgResponse.from(org, now);
}
// ── Admin: revoke temporary grant ─────────────────────────────────────────
@Transactional
public OrgResponse revokeTemp(UUID id, String adminKeyHeader) {
Instant now = clock.now();
requireAdminKey(adminKeyHeader);
OrganizationEntity org = requireOrg(id);
if (org.tempMaxOLevel == null) {
throw unprocessable("no active temporary grant to revoke");
}
OLevel revoked = org.tempMaxOLevel;
org.tempMaxOLevel = null;
org.tempMaxExpiresAt = null;
org.tempMaxGrantedBy = null;
org.tempMaxGrantedReason = null;
org.updatedAt = now;
recordEvent(org.id, OrgEventType.TEMP_REVOKED, revoked, org.earnedOLevel,
"BSF_ADMIN", "grant revoked", now);
return OrgResponse.from(org, now);
}
// ── Owner: self-service key rotation — step 1: rotation secret → TAN ────────
// The rotation secret proves intent; the TAN (sent to registered email) confirms
// it is the legitimate owner. An attacker with only the rotation secret cannot
// complete the rotation without intercepting the registrant's email.
@Transactional
public Map<String, Object> rotateKey(UUID id, String rotationSecretHeader) {
Instant now = clock.now();
OrganizationEntity org = requireOrg(id);
if (!hash(rotationSecretHeader).equals(org.rotationSecretHash)) {
throw new ForbiddenException("invalid rotation secret");
}
if (org.fraudLocked) {
throw unprocessable("Key rotation is locked due to a fraud report. Contact admin@api-index.org to unlock.");
}
resetTanCountIfNeeded(org, now);
if (org.tanRequestCount24h >= 3) {
throw unprocessable("TAN request limit reached (3 per 24h). Contact admin@api-index.org.");
}
String tan = generateTan();
org.pendingTanHash = hash(tan);
org.pendingTanExpires = now.plus(Duration.ofMinutes(5));
org.tanRequestCount24h++;
org.tanLastRequestedAt = now;
org.updatedAt = now;
recordEvent(org.id, OrgEventType.TAN_ISSUED, null, null, "SYSTEM", "key rotation 2FA TAN", now);
String message = buildRotationWarning(org.registrantName, org.id, now);
Map<String, Object> signedNotification = mailSigner.signedNotification(
org.id, "TAN_ISSUED", now.toString(), message);
if (exposeTanInResponse) {
return Map.of("message", message, "tan", tan, "signedNotification", signedNotification);
}
return Map.of("message", message, "signedNotification", signedNotification);
}
private static String buildRotationWarning(String orgName, UUID orgId, Instant requestedAt) {
return String.format(
"Key rotation requested for \"%s\" at %s UTC. " +
"A TAN has been sent to the registered email address. " +
"Submit the TAN to /organizations/%s/rotate-key-with-tan within 5 minutes to complete the rotation. " +
"Did not initiate this? Report fraud immediately: " +
"POST /organizations/%s/notify-key-rotation-fraud",
orgName, requestedAt.toString().replace("T", " ").replace("Z", ""),
orgId, orgId);
}
// ── DNS-challenge key rotation (bot-friendly, ACME DNS-01 analogy) ────────
//
// Step 1: agent presents rotation secret → system issues a challenge token.
// Agent publishes "_apix-rotation.{domain}" TXT "apix-rotate={token}" via its DNS API.
// Step 2: agent presents rotation secret again → system queries DoH, rotates on match.
//
// The challenge token is public by design (it lives in DNS). Security comes from
// requiring the rotation secret on BOTH calls: DNS control alone is insufficient.
// Notification emails are sent at both steps; the token is anonymised in all messages
// so the owner can identify the rotation without the token being useful to a reader.
@Transactional
public Map<String, Object> initiateKeyRotationDns(UUID id, String rotationSecretHeader) {
Instant now = clock.now();
OrganizationEntity org = requireOrg(id);
if (!hash(rotationSecretHeader).equals(org.rotationSecretHash)) {
throw new ForbiddenException("invalid rotation secret");
}
if (org.fraudLocked) {
throw unprocessable("Key rotation is locked due to a fraud report. Contact admin@api-index.org to unlock.");
}
String challengeToken = generateToken();
org.pendingRotationChallengeToken = challengeToken;
org.pendingRotationChallengeExpires = now.plus(Duration.ofMinutes(15));
org.updatedAt = now;
String anon = anonymizeToken(challengeToken);
String notificationMsg = String.format(
"DNS-challenge key rotation initiated for \"%s\" at %s UTC " +
"(challenge token: %s). " +
"A notification has been sent to the registered email address. " +
"Did not initiate this? Report fraud: POST /organizations/%s/notify-key-rotation-fraud",
org.registrantName,
now.toString().replace("T", " ").replace("Z", ""),
anon, id);
recordEvent(org.id, OrgEventType.DNS_ROTATION_INITIATED, null, null,
"SYSTEM", "DNS-challenge rotation initiated; anon token: " + anon, now);
Map<String, Object> signedNotification = mailSigner.signedNotification(
org.id, "DNS_ROTATION_INITIATED", now.toString(), notificationMsg);
return Map.of(
"challengeToken", challengeToken,
"dnsRecord", "_apix-rotation." + org.domain,
"dnsValue", "apix-rotate=" + challengeToken,
"expiresAt", org.pendingRotationChallengeExpires.toString(),
"message", String.format(
"Publish TXT record \"%s\" with value \"%s\" " +
"at your DNS provider, then call /organizations/%s/confirm-key-rotation-dns " +
"with your rotation secret within 15 minutes.",
"_apix-rotation." + org.domain, "apix-rotate=" + challengeToken, id),
"notification", notificationMsg,
"signedNotification", signedNotification);
}
@Transactional
public OrgKeyRotateResponse confirmKeyRotationDns(UUID id, String rotationSecretHeader) {
Instant now = clock.now();
OrganizationEntity org = requireOrg(id);
if (!hash(rotationSecretHeader).equals(org.rotationSecretHash)) {
throw new ForbiddenException("invalid rotation secret");
}
if (org.fraudLocked) {
throw unprocessable("Key rotation is locked due to a fraud report. Contact admin@api-index.org to unlock.");
}
if (org.pendingRotationChallengeToken == null
|| org.pendingRotationChallengeExpires == null
|| !now.isBefore(org.pendingRotationChallengeExpires)) {
throw unprocessable("No valid DNS rotation challenge found. Call /rotate-key-dns first.");
}
String completedToken = org.pendingRotationChallengeToken;
boolean verified;
try {
verified = rotationChallengeVerifier().verify(org.domain, completedToken);
} catch (RuntimeException e) {
throw unprocessable("DNS verification failed: " + e.getMessage());
}
if (!verified) {
throw unprocessable(
"DNS TXT record not found. Ensure \"_apix-rotation." + org.domain + "\" " +
"contains \"apix-rotate=" + completedToken + "\" and retry.");
}
String newApiKey = generateKey("apix_org_");
String newRotationSecret = generateKey("apix_rot_");
org.apiKeyHash = hash(newApiKey);
org.rotationSecretHash = hash(newRotationSecret);
org.pendingRotationChallengeToken = null;
org.pendingRotationChallengeExpires = null;
org.updatedAt = now;
String anon = anonymizeToken(completedToken);
recordEvent(org.id, OrgEventType.KEY_ROTATED, null, null,
"OWNER", "DNS-challenge rotation completed; anon token: " + anon, now);
Map<String, Object> notification = mailSigner.signedNotification(
org.id, "KEY_ROTATED", now.toString(),
String.format("Keys successfully rotated via DNS challenge at %s UTC. Challenge: %s",
now.toString().replace("T", " ").replace("Z", ""), anon));
return new OrgKeyRotateResponse(newApiKey, newRotationSecret, OrgKeyRotateResponse.KEY_WARNING, notification);
}
private RotationChallengeVerifier rotationChallengeVerifier() {
return new RotationChallengeVerifier(new VerificationConfig(
dohUrl, gleifApiUrl, ocApiKey.orElse(""), ocApiUrl, securityTxtTemplate, httpTimeoutMs));
}
// ── Emergency: request TAN (rotation secret compromised) ─────────────────
@Transactional
public Map<String, Object> requestTan(UUID id, OrgTanRequestBody body) {
Instant now = clock.now();
OrganizationEntity org = requireOrg(id);
// Silent on email mismatch — never confirm whether org exists or email matches
if (!org.registrantEmail.equalsIgnoreCase(body.email())) {
return Map.of("message", "If the email matches the registered address, a TAN has been sent.");
}
// Fraud lock: confirmed to the owner but not to random callers (email gate above)
if (org.fraudLocked) {
throw unprocessable("Key rotation is locked due to a fraud report. Contact admin@api-index.org to unlock.");
}
resetTanCountIfNeeded(org, now);
if (org.tanRequestCount24h >= 3) {
throw unprocessable("TAN request limit reached (3 per 24h). Contact admin@api-index.org.");
}
String tan = generateTan();
org.pendingTanHash = hash(tan);
org.pendingTanExpires = now.plus(Duration.ofMinutes(5));
org.tanRequestCount24h++;
org.tanLastRequestedAt = now;
org.updatedAt = now;
recordEvent(org.id, OrgEventType.TAN_ISSUED, null, null, "SYSTEM", "emergency key rotation TAN", now);
// In test mode only — expose TAN in response to allow BDD verification
if (exposeTanInResponse) {
return Map.of("message", "TAN sent to registered email.", "tan", tan);
}
return Map.of("message", "If the email matches the registered address, a TAN has been sent.");
}
// ── Emergency: rotate keys using TAN ──────────────────────────────────────
@Transactional
public OrgKeyRotateResponse rotateKeyWithTan(UUID id, OrgTanRotateRequest body) {
Instant now = clock.now();
OrganizationEntity org = requireOrg(id);
if (org.fraudLocked) {
throw unprocessable("Key rotation is locked due to a fraud report. Contact admin@api-index.org to unlock.");
}
if (org.pendingTanHash == null
|| org.pendingTanExpires == null
|| !now.isBefore(org.pendingTanExpires)) {
throw unprocessable("no valid TAN for this organisation");
}
if (!hash(body.tan()).equals(org.pendingTanHash)) {
throw new ForbiddenException("invalid TAN");
}
String newApiKey = generateKey("apix_org_");
String newRotationSecret = generateKey("apix_rot_");
org.apiKeyHash = hash(newApiKey);
org.rotationSecretHash = hash(newRotationSecret);
org.pendingTanHash = null;
org.pendingTanExpires = null;
org.updatedAt = now;
recordEvent(org.id, OrgEventType.KEY_ROTATED, null, null,
"OWNER", "TAN rotation completed at " + now, now);
Map<String, Object> notification = mailSigner.signedNotification(
org.id, "KEY_ROTATED", now.toString(),
String.format("Keys successfully rotated via TAN at %s UTC",
now.toString().replace("T", " ").replace("Z", "")));
return new OrgKeyRotateResponse(newApiKey, newRotationSecret, OrgKeyRotateResponse.KEY_WARNING, notification);
}
// ── Admin: clear fraud lock after investigation ───────────────────────────
@Transactional
public OrgResponse clearFraudLock(UUID id, String adminKeyHeader) {
Instant now = clock.now();
requireAdminKey(adminKeyHeader);
OrganizationEntity org = requireOrg(id);
if (!org.fraudLocked) {
throw unprocessable("no active fraud lock to clear");
}
org.fraudLocked = false;
org.fraudLockedAt = null;
org.updatedAt = now;
recordEvent(org.id, OrgEventType.FRAUD_LOCK_CLEARED, null, null,
"BSF_ADMIN", "fraud lock cleared after investigation", now);
return OrgResponse.from(org, now);
}
// ── Fraud report — no auth required, always returns generic confirmation ────
// Matching email: clears any pending TAN immediately, sets fraud lock, records event.
// Mismatched email: silently ignored to prevent org-existence enumeration.
// Fraud lock blocks all subsequent rotation attempts until cleared by BSF admin.
@Transactional
public Map<String, Object> reportFraud(UUID id, OrgFraudReportRequest req) {
Instant now = clock.now();
OrganizationEntity org = requireOrg(id);
if (org.registrantEmail.equalsIgnoreCase(req.email())) {
org.pendingTanHash = null;
org.pendingTanExpires = null;
org.pendingRotationChallengeToken = null;
org.pendingRotationChallengeExpires = null;
org.fraudLocked = true;
org.fraudLockedAt = now;
org.updatedAt = now;
recordEvent(org.id, OrgEventType.FRAUD_REPORTED, null, null,
"OWNER", "fraud report: " + (req.notes() != null ? req.notes() : "no notes"), now);
}
return Map.of("message",
"Fraud notification received. If the email matches our records, " +
"the pending rotation has been cancelled and our security team has been alerted.");
}
// ── Helpers ───────────────────────────────────────────────────────────────
private OrganizationEntity requireOrg(UUID id) {
OrganizationEntity org = em.find(OrganizationEntity.class, id);
if (org == null) throw new NotFoundException("organisation not found");
return org;
}
private void requireOrgKey(OrganizationEntity org, String header) {
if (header == null || !hash(header).equals(org.apiKeyHash)) {
throw new ForbiddenException("invalid org API key");
}
}
private void requireAdminKey(String header) {
if (!adminApiKey.equals(header)) {
throw new ForbiddenException("invalid admin API key");
}
}
private void applyVerificationResult(OrganizationEntity org, VerificationResult result, Instant now) {
OLevel previous = org.earnedOLevel;
if (result.succeeded()) {
org.earnedOLevel = result.oLevelAchieved();
org.verificationStatus = VerificationStatus.ACHIEVED;
org.verificationStep = null;
org.verificationError = null;
if (result.detectedLei() != null) org.lei = result.detectedLei();
recordEvent(org.id, OrgEventType.LEVEL_EARNED, previous, result.oLevelAchieved(), "SYSTEM", null, now);
} else {
org.earnedOLevel = result.oLevelAchieved();
org.verificationStatus = VerificationStatus.FAILED;
org.verificationStep = result.blockedAtStep();
org.verificationError = result.message();
recordEvent(org.id, OrgEventType.VERIFICATION_FAILED, previous, result.oLevelAchieved(),
"SYSTEM", result.blockedAtStep() + ": " + result.message(), now);
}
}
private void recordEvent(UUID orgId, OrgEventType type, OLevel from, OLevel to,
String triggeredBy, String notes, Instant now) {
OrgVerificationEventEntity ev = new OrgVerificationEventEntity();
ev.id = UUID.randomUUID();
ev.organizationId = orgId;
ev.eventType = type;
ev.fromLevel = from;
ev.toLevel = to;
ev.triggeredBy = triggeredBy;
ev.notes = notes;
ev.createdAt = now;
em.persist(ev);
}
private void resetTanCountIfNeeded(OrganizationEntity org, Instant now) {
if (org.tanLastRequestedAt != null
&& Duration.between(org.tanLastRequestedAt, now).toHours() >= 24) {
org.tanRequestCount24h = 0;
}
}
private VerificationPipeline pipeline() {
return new VerificationPipeline(new VerificationConfig(
dohUrl, gleifApiUrl, ocApiKey.orElse(""), ocApiUrl, securityTxtTemplate, httpTimeoutMs));
}
private static WebApplicationException unprocessable(String message) {
return new WebApplicationException(
Response.status(422).entity(Map.of("message", message)).build());
}
static String generateKey(String prefix) {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return prefix + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
static String generateToken() {
byte[] bytes = new byte[24];
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
static String generateTan() {
// 6 chars, no ambiguous characters (0/O, 1/I)
String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
SecureRandom rng = new SecureRandom();
StringBuilder sb = new StringBuilder(6);
for (int i = 0; i < 6; i++) sb.append(chars.charAt(rng.nextInt(chars.length())));
return sb.toString();
}
static String anonymizeToken(String token) {
if (token == null || token.length() <= 8) return "****";
return token.substring(0, 4) + "..." + token.substring(token.length() - 4);
}
static String hash(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(64);
for (byte b : bytes) sb.append(String.format("%02x", b));
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
}
@@ -23,8 +23,22 @@ quarkus.http.port=8180
apix.api-key=${APIX_API_KEY:dev-insecure-key-change-in-prod}
# ── Verification ──────────────────────────────────────────────────────────────
apix.dns.doh-url=${APIX_DOH_URL:https://dns.google/resolve}
apix.gleif.api-url=${GLEIF_API_URL:https://api.gleif.org/api/v1}
apix.opencorporates.api-key=${OPENCORPORATES_API_KEY:}
apix.opencorporates.api-url=${OPENCORPORATES_API_URL:https://api.opencorporates.com/v0.4}
apix.hygiene.security-txt-url-template=${APIX_SECURITY_TXT_TEMPLATE:https://{domain}/.well-known/security.txt}
apix.verification.http-timeout-ms=${APIX_VERIFICATION_TIMEOUT_MS:5000}
apix.org.tan.expose-in-response=false
# ── Mail signing (Ed25519) ────────────────────────────────────────────────────
# PKCS#8 private key and SubjectPublicKeyInfo public key, Base64 standard encoding.
# If blank, an ephemeral key pair is generated at startup (dev/test only).
apix.mail.signing.private-key-base64=${APIX_MAIL_SIGNING_PRIVATE_KEY:}
apix.mail.signing.public-key-base64=${APIX_MAIL_SIGNING_PUBLIC_KEY:}
# Key ID published in signed payloads and the /mail-signing-keys endpoint.
# Convention: YYYY-MM, rotated every 6 months.
apix.mail.signing.kid=${APIX_MAIL_SIGNING_KID:dev}
apix.sanctions.cache-path=${SANCTIONS_CACHE_PATH:./sanctions-cache}
# ── Logging ───────────────────────────────────────────────────────────────────
@@ -33,3 +47,30 @@ quarkus.log.console.json=false
# ── Health ────────────────────────────────────────────────────────────────────
quarkus.smallrye-health.root-path=/q/health
# ── Observability ─────────────────────────────────────────────────────────────
# HTTP server request metrics (http_server_requests_seconds histogram) are
# auto-instrumented by quarkus-micrometer. Prometheus scrapes /q/metrics.
quarkus.micrometer.enabled=true
quarkus.micrometer.export.prometheus.enabled=true
# Without match-patterns, the uri label is the raw request path and UUIDs create
# unbounded cardinality. Order matters: specific sub-paths before the catch-all.
quarkus.micrometer.binder.http-server.match-patterns=\
/services/[0-9a-f-]+/replacements=/services/{id}/replacements,\
/services/[0-9a-f-]+/history=/services/{id}/history,\
/services/[0-9a-f-]+/olevel=/services/{id}/olevel,\
/services/[0-9a-f-]+=/services/{id},\
/organizations/[0-9a-f-]+/verify=/organizations/{id}/verify,\
/organizations/[0-9a-f-]+/request-upgrade=/organizations/{id}/request-upgrade,\
/organizations/[0-9a-f-]+/earned-level=/organizations/{id}/earned-level,\
/organizations/[0-9a-f-]+/temp-grant=/organizations/{id}/temp-grant,\
/organizations/[0-9a-f-]+/rotate-key=/organizations/{id}/rotate-key,\
/organizations/[0-9a-f-]+/request-tan=/organizations/{id}/request-tan,\
/organizations/[0-9a-f-]+/rotate-key-with-tan=/organizations/{id}/rotate-key-with-tan,\
/organizations/[0-9a-f-]+/rotate-key-dns=/organizations/{id}/rotate-key-dns,\
/organizations/[0-9a-f-]+/confirm-key-rotation-dns=/organizations/{id}/confirm-key-rotation-dns,\
/organizations/[0-9a-f-]+/notify-key-rotation-fraud=/organizations/{id}/notify-key-rotation-fraud,\
/organizations/[0-9a-f-]+/fraud-lock=/organizations/{id}/fraud-lock,\
/organizations/[0-9a-f-]+/audit-log=/organizations/{id}/audit-log,\
/organizations/[0-9a-f-]+/event-feed=/organizations/{id}/event-feed,\
/organizations/[0-9a-f-]+=/organizations/{id}
@@ -0,0 +1,122 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="010" author="apix">
<createTable tableName="organizations">
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="registrant_name" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="registrant_email" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="registrant_jurisdiction" type="varchar(10)">
<constraints nullable="false"/>
</column>
<column name="registrant_org_type" type="varchar(50)">
<constraints nullable="false"/>
</column>
<!-- Domain used for DNS and hygiene verification -->
<column name="domain" type="varchar(255)">
<constraints nullable="false"/>
</column>
<!-- GLEIF LEI — populated during O-2 if a GLEIF record is found -->
<column name="lei" type="varchar(20)"/>
<!-- Credentials — stored as SHA-256 hex hashes, never in plaintext -->
<column name="api_key_hash" type="varchar(64)">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_org_api_key_hash"/>
</column>
<!-- Rotation secret exposure → always triggers admin+TAN process -->
<column name="rotation_secret_hash" type="varchar(64)">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_org_rotation_secret_hash"/>
</column>
<!-- O-level state machine -->
<column name="target_o_level" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="earned_o_level" type="varchar(50)" defaultValue="UNVERIFIED">
<constraints nullable="false"/>
</column>
<!-- PENDING | VERIFYING | ACHIEVED | FAILED | MANUAL_REVIEW -->
<column name="verification_status" type="varchar(50)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<!-- Which step is in progress or last failed -->
<column name="verification_step" type="varchar(100)"/>
<column name="verification_error" type="text"/>
<!-- Random token org owner must publish as DNS TXT _apix-verification.{domain} -->
<column name="dns_verification_token" type="varchar(64)"/>
<!-- BSF temporary elevated grant -->
<column name="temp_max_o_level" type="varchar(50)"/>
<column name="temp_max_expires_at" type="timestamptz"/>
<column name="temp_max_granted_by" type="varchar(255)"/>
<column name="temp_max_granted_reason" type="text"/>
<!-- Emergency key rotation via admin TAN (one active TAN per org) -->
<column name="pending_tan_hash" type="varchar(64)"/>
<column name="pending_tan_expires_at" type="timestamptz"/>
<!-- Rate-limit: max 3 TAN requests per 24h -->
<column name="tan_request_count_24h" type="integer" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="tan_last_requested_at" type="timestamptz"/>
<!-- Fraud lock: set by /notify-key-rotation-fraud; blocks all rotation until cleared by BSF admin -->
<column name="fraud_locked" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="fraud_locked_at" type="timestamptz"/>
<column name="created_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
<column name="updated_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
</createTable>
<!-- Append-only audit trail of every O-level change event -->
<createTable tableName="org_verification_events">
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="organization_id" type="uuid">
<constraints nullable="false"
foreignKeyName="fk_org_event_org"
references="organizations(id)"
deleteCascade="true"/>
</column>
<!-- REGISTERED | LEVEL_EARNED | UPGRADE_REQUESTED | VERIFICATION_FAILED |
TEMP_GRANTED | TEMP_REVOKED | TEMP_EXPIRED | LEVEL_REVOKED |
KEY_ROTATED | TAN_ISSUED | DNS_ROTATION_INITIATED |
FRAUD_REPORTED | FRAUD_LOCK_CLEARED -->
<column name="event_type" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="from_level" type="varchar(50)"/>
<column name="to_level" type="varchar(50)"/>
<!-- SYSTEM | OWNER | BSF_ADMIN -->
<column name="triggered_by" type="varchar(50)"/>
<column name="notes" type="text"/>
<column name="created_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="org_verification_events" indexName="idx_org_events_org_id">
<column name="organization_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="011" author="apix">
<!-- DNS-challenge key rotation fields (bot-friendly, ACME DNS-01 analogy).
Token is stored plaintext — it is a public DNS value, not a secret. -->
<addColumn tableName="organizations">
<column name="pending_rotation_challenge_token" type="varchar(64)"/>
<column name="pending_rotation_challenge_expires_at" type="timestamptz"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -14,5 +14,7 @@
<include file="changes/007-service-replacements.xml" relativeToChangelogFile="true"/>
<include file="changes/008-sunset-at.xml" relativeToChangelogFile="true"/>
<include file="changes/009-iot-profiles.xml" relativeToChangelogFile="true"/>
<include file="changes/010-organizations.xml" relativeToChangelogFile="true"/>
<include file="changes/011-org-rotation-challenge.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>