CuanDeOro
SumSub KYC integration · Non-custodial design v2.0
← Hub

SumSub KYC integration · diseño non-custodial

Cuandeoro NO custodia documentos · SumSub es processor único · Marco scope 2026-06-01

SCOPE REGULATORIO FIRMADO MARCO 2026-06-01: Cuandeoro NO debe ser tratado como CASP, VASP, exchange, custodian, broker ni payment institution. Cualquier flujo que introduzca custodia de docs, custody wallet, ejecución de órdenes o portfolio management cambia el modelo legal y requiere revisión con abogado MiCA/AML5 antes de implementar.

Contenido

  1. Separación de roles · controller / processor / data subject
  2. Datos permitidos vs prohibidos
  3. Flujo normal y flujo privacidad reforzada
  4. Modelo PostgreSQL · 4 tablas
  5. Endpoints backend · 4 rutas
  6. Validación webhook SumSub
  7. Idempotencia de eventos
  8. Política GDPR
  9. Advertencia legal final

1. Separación de roles

Cuandeoro Limited

Data controller para metadata mínima

· Decide finalidad: KYC compliance + on-chain anchor
· Almacena: IDs externos + hashes + status
· No procesa documentos
· No accede a PII en claro
· Plataforma non-custodial

SumSub

Processor / Subprocessor

· Ejecuta KYC/KYB/AML
· Recibe y custodia documentos PII
· Devuelve solo IDs + attestation hashes
· Firma DPA con Cuandeoro
· Mantiene audit trail propio

Usuario

Data subject + titular

· Entrega docs directamente a SumSub
· Conserva originales propios
· Ejerce derechos GDPR Art. 15-22
· Para Cuandeoro: solo identificado por stellar_address + sumsub_applicant_id

2. Datos permitidos vs prohibidos

DatoEstadoRazón
sumsub_applicant_idPermitidoID externo, no es PII en sí
sumsub_inspection_idPermitidoID externo
verification_statusPermitidoEstado alto nivel
verification_levelPermitidoTier KYC alcanzado
risk_score (0-100)PermitidoMétrica derivada, sin razones
pep_status / sanctions_status / adverse_media_statusPermitidoFlags binarias, sin detalle de matches
country_of_residence (ISO-2)PermitidoNecesario para enforcement geo
provider_attestation_hashPermitidoSHA-256 de la attestation completa de SumSub
identity_commitment_hashPermitidoSHA-256(applicant_id‖stellar_address‖nonce) · anclaje opcional on-chain
raw_document_hashPermitidoSolo si SumSub lo expone vía API
webhook_signature_hashPermitidoAudit del HMAC del último webhook procesado
timestamp_verified / timestamp_last_reviewed / timestamp_expiresPermitidoLifecycle
audit_event_id (externo SumSub)PermitidoTrazabilidad cruzada
— línea roja —
Imagen pasaporte / DNI escaneadoProhibidoCustody = CASP risk + GDPR Art. 9
Selfie / liveness imageProhibidoBiometría = categoría especial GDPR
Proof of address completo (PDF, JPG)ProhibidoPII directo · solo hash si necesario
Seed phrases / private keys de clientesProhibidoCustodia de wallet · convierte en CASP
PDF copias de documentos personalesProhibidoPII directo
Documentos apostillados La Haya completosProhibidoExcepción solo si retención legal expresa requiere · normalmente solo hash + ref externa

3. Flujos

3.1 · Flujo normal (KYC documental via SumSub)

1Usuario inicia onboarding en cuandeoro.ie → POST /kyc/start
2Cuandeoro backend crea applicant en SumSub API (server-to-server) → recibe applicant_id
3Cuandeoro devuelve al frontend URL/token SumSub SDK · usuario sube docs DIRECTO a SumSub (NO pasa por Cuandeoro)
4SumSub ejecuta KYC + sanctions + PEP + adverse media · genera review
5SumSub dispara webhook a POST /kyc/sumsub/webhook firmado HMAC-SHA256
6Cuandeoro valida firma → si OK procesa idempotente → actualiza kyc_profiles con status + hashes
7Cuandeoro nunca descarga ni almacena documentos personales

3.2 · Flujo privacidad reforzada (KYC sin docs visibles para Cuandeoro)

1Usuario alta nivel "enhanced privacy" en onboarding
2SumSub ejecuta verificación · usuario entrega docs solo a SumSub o a notario/certificador externo
3SumSub o notario emite attestation verificable (firma + ref externa)
4Cuandeoro recibe attestation hash + metadata mínima → almacena en kyc_attestations
5Si hay apostilla La Haya: Cuandeoro NO custodia el doc · solo: emisor + país + fecha + tipo + ref externa + hash
6Verificación posterior: auditor consulta directamente al notario/registro externo usando external_reference_url

4. Modelo PostgreSQL

Schema completo idempotente generado en /srv/cuandeoro_kyc/schema.sql. Resumen:

4.1 · kyc_profiles

1 fila por usuario/wallet · ÚNICAMENTE metadata aprobada · NO PII en ningún campo.

Campos clave: stellar_address, sumsub_applicant_id, verification_status, verification_level, risk_score, risk_band, pep_status, sanctions_status, adverse_media_status, country_of_residence, provider_attestation_hash, identity_commitment_hash, timestamp_verified, deleted_at.

Constraints: UNIQUE por stellar_address y por sumsub_applicant_id · CHECK verification_status IN ('init','pending','on_hold','approved','rejected','expired','recheck_required').

4.2 · kyc_provider_events

Webhook log idempotente · UNIQUE por (provider, provider_event_id) · solo guarda payload_hash + payload_summary (JSONB sin PII) · no el payload completo.

Estados: received, processed, rejected_invalid_signature, rejected_duplicate, processing_error.

4.3 · kyc_attestations

Para "privacy enhanced" · attestations notariales / apostilladas / certificadas externas. Cuandeoro almacena solo: attestation_type, issuer_name, issuer_country, issuer_registry_id, issuance_date, document_hash, external_reference_url, external_reference_id, verification_status.

Tipos: hague_apostille, notarized_kyb_corporate, sworn_translation, other_certification.

4.4 · kyc_audit_log

Trazabilidad inmutable de cada operación: profile_create/read/update/delete, webhook_received/processed/rejected, gdpr_art15_export/art17_erasure/art20_portability, aml_retention_check/expired. NUNCA PII · solo metadata operacional.

Trigger natural · cualquier query relevante debe pasar por funciones SECURITY DEFINER que loggean automático.

5. Endpoints backend

POST/kyc/start
Auth usuario autenticado (sesión Cuandeoro)
Request body
{
  "stellar_address": "GAB...XYZ",
  "verification_level": "basic_kyc" | "enhanced_due_diligence" | "kyb_corporate",
  "country_of_residence": "ES",
  "enhanced_privacy": false
}
Server-side flow
1. Verificar que stellar_address pertenece a la sesión
2. Crear applicant en SumSub vía POST /resources/applicants?levelName=...
3. INSERT kyc_profiles (status='init', applicant_id, level, country)
4. INSERT kyc_audit_log (operation='profile_create')
5. Generar accessToken SumSub para SDK del frontend
6. Devolver al frontend: { sumsub_token, sdk_init_params }
Response 200
{
  "kyc_profile_id": "uuid",
  "sumsub_token": "_act-jwt-...",
  "sumsub_level": "basic-kyc-level",
  "sdk_config": { ... }
}
POST/kyc/sumsub/webhook
Auth HMAC-SHA256 firma SumSub en header (NO sesión usuario)
Request headers
X-Payload-Digest: <HMAC-SHA256 hex del body con secret compartido>
X-Payload-Digest-Alg: HMAC_SHA256_HEX
X-Sumsub-Webhook-Timestamp: 1717221234
Content-Type: application/json
Server-side flow
1. Leer body raw + headers
2. Verificar X-Sumsub-Webhook-Timestamp dentro de skew (±5 min)
3. Calcular HMAC-SHA256(body, SUMSUB_WEBHOOK_SECRET)
   → compare constant-time con X-Payload-Digest
4. Si firma inválida:
   - INSERT kyc_provider_events (signature_valid=false, status='rejected_invalid_signature')
   - return 401
5. Si firma OK:
   - extract event_id, applicant_id del body
   - INSERT kyc_provider_events ON CONFLICT (provider, provider_event_id) DO NOTHING
   - si duplicado: return 200 (idempotente)
   - si nuevo: procesar según event_type
     · applicantReviewed → UPDATE kyc_profiles SET status='approved'|'rejected', risk_score, etc.
     · applicantPending → status='pending'
     · applicantOnHold → status='on_hold'
6. INSERT kyc_audit_log (operation='webhook_processed', external_event_ref=event_id)
7. return 200 { "status": "ok" } SIEMPRE (SumSub reintenta si no es 2xx)
GET/kyc/status
Auth usuario autenticado
Query ?stellar_address=GAB...
Server-side flow
1. Verificar stellar_address ∈ sesión
2. SELECT desde kyc_profiles_active WHERE stellar_address = $1
3. NO devolver: applicant_id raw (PII proxy), hashes binarios
4. SÍ devolver: status, level, risk_band (no score exacto al usuario),
   country, timestamp_verified, timestamp_expires
5. INSERT kyc_audit_log (operation='profile_read', actor=user)
Response 200
{
  "verification_status": "approved",
  "verification_level": "basic_kyc",
  "risk_band": "low",
  "country_of_residence": "ES",
  "timestamp_verified": "2026-05-30T10:00:00Z",
  "timestamp_expires": "2027-05-30T10:00:00Z",
  "compliance_flags": {
    "pep": "clear",
    "sanctions": "clear",
    "adverse_media": "clear"
  }
}
POST/kyc/recheck
Auth admin compliance OR usuario (rate-limited)
Request body
{
  "stellar_address": "GAB...XYZ",
  "reason": "user_request" | "periodic_review" | "risk_signal" | "regulatory_inquiry"
}
Server-side flow
1. SELECT kyc_profile_id FROM kyc_profiles WHERE stellar_address=$1 AND deleted_at IS NULL
2. POST a SumSub /resources/applicants/{applicant_id}/reset/{rejection_label}
   o /resources/applicants/{applicant_id}/proceed
3. UPDATE kyc_profiles SET verification_status='recheck_required', timestamp_last_reviewed=NOW()
4. INSERT kyc_audit_log (operation='profile_update_status', details={reason, triggered_by})
5. SumSub eventualmente dispara nuevo webhook con estado actualizado

6. Validación webhook SumSub

SumSub firma cada webhook con HMAC-SHA256 usando un secret compartido configurado en su dashboard. Validación correcta es crítica · sin esto, atacante puede fabricar eventos "approved" para wallets que controla.

Algoritmo de verificación (Python)

import hmac, hashlib, time
from fastapi import Request, HTTPException

SUMSUB_WEBHOOK_SECRET = os.environ['SUMSUB_WEBHOOK_SECRET']  # de Vault
MAX_TIMESTAMP_SKEW_SEC = 300  # 5 min

async def verify_sumsub_webhook(request: Request) -> bytes:
    body = await request.body()
    signature_hex = request.headers.get('x-payload-digest')
    digest_alg    = request.headers.get('x-payload-digest-alg', '')
    timestamp_str = request.headers.get('x-sumsub-webhook-timestamp')

    if not signature_hex or not timestamp_str:
        raise HTTPException(401, 'missing signature headers')

    if digest_alg != 'HMAC_SHA256_HEX':
        raise HTTPException(401, f'unsupported alg {digest_alg}')

    # Skew check
    try:
        ts = int(timestamp_str)
    except ValueError:
        raise HTTPException(401, 'invalid timestamp')
    if abs(time.time() - ts) > MAX_TIMESTAMP_SKEW_SEC:
        raise HTTPException(401, 'timestamp skew exceeded')

    # HMAC en bytes · constant-time compare
    expected = hmac.new(
        SUMSUB_WEBHOOK_SECRET.encode(),
        body,
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, signature_hex):
        raise HTTPException(401, 'invalid signature')

    return body
NO almacenar SUMSUB_WEBHOOK_SECRET en .env plaintext. Path Vault: cuandeoro/services/sumsub_webhook con field secret. AppRole específico cuandeoro-kyc-app con scope mínimo de read solo a ese path.

7. Idempotencia de eventos

SumSub puede reintentar el mismo webhook hasta 10 veces si no recibe 2xx. Sin idempotencia, mismo event se procesaría múltiples veces → estado inconsistente, multiples UPDATEs duplicados, audit log infectado.

Mecanismo

-- Tabla kyc_provider_events tiene:
CONSTRAINT one_event_per_provider_id UNIQUE (provider, provider_event_id)

-- En el handler:
INSERT INTO kyc_provider_events (...)
VALUES (...)
ON CONFLICT (provider, provider_event_id) DO NOTHING
RETURNING id;

-- Si RETURNING devuelve 0 filas:
--   → evento ya procesado (duplicado · idempotente)
--   → responder 200 con {"status":"already_processed"}
-- Si RETURNING devuelve 1 fila:
--   → evento nuevo · proceder a actualizar kyc_profiles + audit_log

Garantías

8. Política GDPR

8.1 · Data minimization (Art. 5.1.c)

Aplicado en diseño: ningún campo PII directo en ninguna tabla. Solo IDs externos, hashes, status, country. Cumplido por arquitectura, no por política.

8.2 · No document custody

Aplicado en diseño: ningún flujo descarga ni almacena documentos. Schema no tiene columna BYTEA grande ni FK a object storage. kyc_attestations.document_hash es SHA-256 (32 bytes), nunca el doc.

8.3 · Retention periods

CategoríaRetentionBase legal
kyc_profiles (active)Mientras user activoEjecución del contrato Art. 6.1.b
kyc_profiles (deleted_at NOT NULL)5 años post-soft-deleteAML5 customer due diligence record keeping
kyc_provider_events5 añosAML5 + audit trail
kyc_attestations (verified)5 años post-expiryAML5
kyc_audit_log10 añosDefensa ante regulador + chain custody

8.4 · Right to erasure (Art. 17) where legally possible

SELECT gdpr_art17_purge_profile('GAB...XYZ', 'gdpr_art17_user_request');

-- Función SECURITY DEFINER · OWNER cuandeoro_kyc_auditor (inmutable)
-- Hace soft-delete, mantiene metadata mínima por AML retention 5 años
-- Después: aml_retention_hard_delete() job nocturno purga hard tras 5 años
Right to erasure NO es absoluto. Conflicto entre Art. 17 GDPR y AML5 record keeping (5 años):
· Soft delete inmediato · oculto de queries normales
· Hard delete diferido 5 años post-deletion
· Justificación legal: Art. 17.3.b (compliance with legal obligation)
· Usuario también debe contactar SumSub para erasure en provider — Cuandeoro no puede borrar lo que SumSub custodia.

8.5 · Right of access (Art. 15) / portability (Art. 20)

-- Función helper que exporta TODO lo que Cuandeoro tiene del usuario
CREATE FUNCTION gdpr_art15_export_profile(p_stellar_address VARCHAR)
RETURNS JSONB · SECURITY DEFINER
-- Devuelve JSONB con todo de kyc_profiles + audit_log entries de ese usuario
-- + nota: "Para datos completos KYC contactar SumSub directamente con applicant_id X"

8.6 · DPA con SumSub

Este diseño preserva posición no-CASP de Cuandeoro Limited (Ireland).

Cualquiera de las siguientes cambia el modelo regulatorio y exige revisión legal MiCA / AML5 / VASP / E-Money antes de implementar:

· Custodiar wallets de clientes (incluso "temporal") → MiCA Art. 75 CASP custody
· Ejecutar órdenes de compra/venta de criptoactivos en nombre del cliente → CASP execution
· Recibir y transmitir órdenes de clientes hacia exchanges → CASP RTO
· Almacenar private keys o seed phrases de clientes (incluso cifradas) → CASP custody
· Tokenizar y emitir tokens propios con referencia a valor → ART/EMT MiCA Título III/IV
· Operar mercado / matching engine → CASP operation of trading platform
· Portfolio management / advice → CASP portfolio management
· Almacenar PII directo (docs, fotos, selfies) → cambia exposure GDPR Art. 9 (categoría especial)

Recomendación: antes de cada cambio que toque flujo de fondos, tokens, wallets o documentos, revisar con abogado MiCA acreditado (Mason Hayes & Curran, William Fry, Matheson) y con DPO/abogado RGPD (DPC Irlanda).

Sello operativo: este documento NO es asesoramiento legal · es diseño técnico que refleja el scope confirmado por Marco 2026-06-01.