ops: add CI/CD pipeline, a/b rolling deploy, Gitea Actions workflow
Deploy to Production / deploy (push) Failing after 10s
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:
+26
@@ -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) {}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
@@ -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) {}
|
||||
+11
@@ -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
|
||||
) {}
|
||||
+13
@@ -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.";
|
||||
}
|
||||
+17
@@ -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
|
||||
) {}
|
||||
+25
@@ -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) {}
|
||||
+7
@@ -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) {}
|
||||
+16
@@ -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) {}
|
||||
+42
@@ -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;
|
||||
}
|
||||
+117
@@ -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;
|
||||
}
|
||||
}
|
||||
+29
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -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()
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
+197
@@ -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();
|
||||
}
|
||||
}
|
||||
+159
@@ -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");
|
||||
}
|
||||
}
|
||||
+649
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user