Task — engineering-spec@1

"EMAIL DKIM signing + ARC chain forward + BIMI brand indicator — RFC 6376 + RFC 8617 + BIMI 1.0 per-tenant outbound auth"

doneTASK-EMAIL-004
module email · class product · priority p0 · created 2026-05-17 · shipped 2026-05-23
depends on TASK-EMAIL-001 · blocks TASK-EMAIL-009

§1 — Description (BCP-14 normative)

The EMAIL service MUST ship DKIM signing + ARC chain forward + BIMI brand indicator at services/email/src/{dkim,arc,bimi,dns}/, per-tenant Ed25519 keypair generated at provisioning, DNS setup wizard, RFC-conformant signing, and 5 memory audit kinds.

  1. MUST define closed dkim_outcome enum: ('signed_ed25519','signed_rsa','sign_failed_no_key','sign_failed_kms') per DEC-1473. Cardinality 4.
  1. MUST generate per-tenant DKIM keypair per DEC-1470 at TASK-TEN-001 provisioning:
  1. MUST define tenant_dkim_keys table at migration 0002: (tenant_id UUID NOT NULL, key_kind TEXT NOT NULL CHECK (key_kind IN ('ed25519','rsa2048')), selector TEXT NOT NULL DEFAULT 'cyberos', private_key_kms_blob BYTEA NOT NULL, public_key_dns_txt TEXT NOT NULL, kms_key_id TEXT NOT NULL, generated_at TIMESTAMPTZ NOT NULL DEFAULT now(), revoked_at TIMESTAMPTZ, PRIMARY KEY (tenant_id, key_kind, selector)).
  1. MUST sign outbound messages via dkim/signer.rs::sign(message, tenant_id) per RFC 6376:
  1. MUST support ARC chain forward per DEC-1471 + RFC 8617 via arc/chain_forward.rs. For forwarded inbound mail:
  1. MUST attach BIMI brand indicator per DEC-1472 via bimi/mod.rs:
  1. MUST define tenant_dns_setup at migration 0003: (tenant_id UUID PRIMARY KEY, custom_domain TEXT, dkim_txt_published BOOLEAN, spf_txt_published BOOLEAN, dmarc_txt_published BOOLEAN, dmarc_policy TEXT, bimi_txt_published BOOLEAN, vmc_cert_url TEXT, last_verified_at TIMESTAMPTZ, verification_failures INT NOT NULL DEFAULT 0).
  1. MUST expose DNS setup wizard POST /v1/admin/tenants/{tid}/email/dns-setup. Returns required TXT records (DKIM public key + SPF + DMARC + BIMI). Tenant admin publishes; verifier polls.
  1. MUST verify DNS via dns/verifier.rs::verify(tenant_id). Daily job:
  1. MUST emit 5 memory audit kinds per DEC-1475.
  1. MUST thread trace_id end-to-end.
  1. MUST NOT share keys across tenants (per DEC-1470).
  1. MUST NOT attach BIMI without DMARC enforcement (per DEC-1472).

§2 — Why this design (rationale)

Why per-tenant DKIM (DEC-1470)? Compromise scoping; shared key = single-failure compromise of every tenant.

Why Ed25519 + RSA dual (DEC-1470)? Legacy receivers (some enterprise mail servers) don't yet support Ed25519; RSA fallback ensures delivery.

Why ARC (DEC-1471)? Mailing-list forwards break SPF/DKIM; ARC preserves original auth verdict for receivers.

Why BIMI requires DMARC (DEC-1472)? BIMI spec mandates p=quarantine+ to prevent abuse — spoofers can't get brand-recognised inboxes.


§3 — API contract

-- 0002_tenant_dkim_keys.sql
CREATE TABLE tenant_dkim_keys (
  tenant_id UUID NOT NULL,
  key_kind TEXT NOT NULL CHECK (key_kind IN ('ed25519','rsa2048')),
  selector TEXT NOT NULL DEFAULT 'cyberos',
  private_key_kms_blob BYTEA NOT NULL,
  public_key_dns_txt TEXT NOT NULL,
  kms_key_id TEXT NOT NULL,
  generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  revoked_at TIMESTAMPTZ,
  PRIMARY KEY (tenant_id, key_kind, selector)
);
ALTER TABLE tenant_dkim_keys ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_dkim_keys_rls ON tenant_dkim_keys
  USING (tenant_id = current_setting('auth.tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
REVOKE UPDATE, DELETE ON tenant_dkim_keys FROM cyberos_app;
GRANT UPDATE (revoked_at) ON tenant_dkim_keys TO cyberos_app;

-- 0003_tenant_dns_setup.sql
CREATE TABLE tenant_dns_setup (
  tenant_id UUID PRIMARY KEY,
  custom_domain TEXT,
  dkim_txt_published BOOLEAN NOT NULL DEFAULT false,
  spf_txt_published BOOLEAN NOT NULL DEFAULT false,
  dmarc_txt_published BOOLEAN NOT NULL DEFAULT false,
  dmarc_policy TEXT,
  bimi_txt_published BOOLEAN NOT NULL DEFAULT false,
  vmc_cert_url TEXT,
  last_verified_at TIMESTAMPTZ,
  verification_failures INT NOT NULL DEFAULT 0,
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE tenant_dns_setup ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_dns_setup_rls ON tenant_dns_setup
  USING (tenant_id = current_setting('auth.tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
REVOKE DELETE ON tenant_dns_setup FROM cyberos_app;
GRANT UPDATE (dkim_txt_published, spf_txt_published, dmarc_txt_published, dmarc_policy,
              bimi_txt_published, vmc_cert_url, last_verified_at, verification_failures, custom_domain, updated_at)
  ON tenant_dns_setup TO cyberos_app;

Endpoints:

POST   /v1/admin/tenants/{tid}/email/dns-setup        (tenant_admin)
POST   /v1/admin/tenants/{tid}/email/dns-verify       (tenant_admin)
POST   /v1/admin/tenants/{tid}/email/bimi-enable      (tenant_admin)

§4 — Acceptance criteria

  1. dkim_outcome cardinality 4.
  2. DKIM Ed25519 sign — outbound message gets DKIM-Signature header with a=ed25519-sha256.
  3. RSA fallback — legacy receiver fixture → RSA signature attached as additional header.
  4. Per-tenant key isolation — tenant A's key cannot sign for tenant B (RLS enforced).
  5. DNS wizard returns TXT records — POST returns expected DKIM/SPF/DMARC/BIMI TXT values.
  6. DNS verification daily — wizard records publication state; verifier polls + updates.
  7. ARC chain extended — forwarded message has ARC-Seal added.
  8. BIMI requires DMARC — bimi-enable without DMARC=quarantine → 412.
  9. SVG tinify — uploaded SVG processed to BIMI-compliant Tiny PS.
  10. 5 memory audit kinds emitted.
  11. KMS unavailable → sign_failed_kms outcome + sev-1 audit.
  12. Key missing for tenant → sign_failed_no_key.
  13. DNS verification failure persisted — failures counter increments.
  14. Trace_id end-to-end.
  15. Cross-tenant RLS denied.
  16. VMC cert URL optional — BIMI works without VMC (no verified mark badge).
  17. Selector configurable — default 'cyberos'; tenant can change.
  18. Provisioning integration — new tenant gets DKIM keys auto-generated.
  19. Revoked key not used for signing — revoked_at set → skipped.
  20. PII scrub — DNS records non-PII; raw IP not in audit.

§5 — Verification

#[tokio::test]
async fn dkim_signs_outbound_message() {
    let ctx = TestContext::with_provisioned_tenant().await;
    let msg = ctx.outbound_message("alice@acme.cyberos.world", "bob@example.com").await;
    let signed = ctx.dkim_sign(ctx.tenant_id, msg).await;
    assert!(signed.headers().contains_key("DKIM-Signature"));
    let sig: &str = signed.headers().get("DKIM-Signature").unwrap();
    assert!(sig.contains("a=ed25519-sha256"));
    assert!(sig.contains("s=cyberos"));
}

#[tokio::test]
async fn cross_tenant_key_isolation() {
    let ctx = TestContext::with_two_tenants().await;
    let r = ctx.as_tenant_a().dkim_sign_for_tenant(ctx.tenant_b_id, "test").await;
    assert!(r.is_err());  // RLS rejects
}

#[tokio::test]
async fn bimi_requires_dmarc() {
    let ctx = TestContext::with_provisioned_tenant().await;
    let r = ctx.bimi_enable(ctx.tenant_id).await;
    assert_eq!(r.status(), 412);  // DMARC not yet set
    ctx.set_dmarc_policy(ctx.tenant_id, "quarantine").await;
    let r = ctx.bimi_enable(ctx.tenant_id).await;
    assert_eq!(r.status(), 200);
}

#[tokio::test]
async fn provisioning_generates_keys() {
    let ctx = TestContext::new().await;
    let tid = ctx.provision_tenant().await;
    let count: i64 = sqlx::query_scalar("SELECT count(*) FROM tenant_dkim_keys WHERE tenant_id=$1")
        .bind(tid).fetch_one(&ctx.pool).await.unwrap();
    assert_eq!(count, 2);  // ed25519 + rsa
}

// 5.5..5.10

§7 — Dependencies

Upstream: TASK-EMAIL-001. Cross-module: TASK-TEN-001 (keygen at provisioning), TASK-PORTAL-002 (BIMI logo), TASK-AI-003, TASK-MEMORY-111. Downstream: TASK-EMAIL-009 (outbound send).

§10 — Failure modes

FailureDetectionOutcomeRecovery
KMS unavailabletimeoutsign_failed_kms; sev-1KMS recovery
Key missinglookup misssign_failed_no_keyRegenerate via wizard
DNS not propagatedverifier pollfailures++; sev-2 at 5+ failuresTenant admin fixes DNS
ARC chain corrupted upstreamverify failcv=fail; forward with verdict; sev-3Inherent
BIMI SVG > 32KBtinifier limit400Tenant slims logo
VMC URL unreachablefetch failBIMI works without verified mark; sev-3Inherent
Cross-tenant signing attemptRLSInherentNone
Revoked key signing attemptrevoked_at checksign_failed_no_keyGenerate new
DNS provider rate limitpoll backoffsev-3Inherent
Selector collisionpartial uniquetenant chooses uniqueInherent
Custom domain mismatchwizard400Fix domain config
KMS rotation breaks signingkey archiveOld signed msgs verify with old pub keyInherent

§11 — Implementation notes


End of TASK-EMAIL-004 spec.