Cuandeoro NO custodia documentos · SumSub es processor único · Marco scope 2026-06-01
stellar_address + sumsub_applicant_id
| Dato | Estado | Razón |
|---|---|---|
| sumsub_applicant_id | Permitido | ID externo, no es PII en sí |
| sumsub_inspection_id | Permitido | ID externo |
| verification_status | Permitido | Estado alto nivel |
| verification_level | Permitido | Tier KYC alcanzado |
| risk_score (0-100) | Permitido | Métrica derivada, sin razones |
| pep_status / sanctions_status / adverse_media_status | Permitido | Flags binarias, sin detalle de matches |
| country_of_residence (ISO-2) | Permitido | Necesario para enforcement geo |
| provider_attestation_hash | Permitido | SHA-256 de la attestation completa de SumSub |
| identity_commitment_hash | Permitido | SHA-256(applicant_id‖stellar_address‖nonce) · anclaje opcional on-chain |
| raw_document_hash | Permitido | Solo si SumSub lo expone vía API |
| webhook_signature_hash | Permitido | Audit del HMAC del último webhook procesado |
| timestamp_verified / timestamp_last_reviewed / timestamp_expires | Permitido | Lifecycle |
| audit_event_id (externo SumSub) | Permitido | Trazabilidad cruzada |
| — línea roja — | ||
| Imagen pasaporte / DNI escaneado | Prohibido | Custody = CASP risk + GDPR Art. 9 |
| Selfie / liveness image | Prohibido | Biometría = categoría especial GDPR |
| Proof of address completo (PDF, JPG) | Prohibido | PII directo · solo hash si necesario |
| Seed phrases / private keys de clientes | Prohibido | Custodia de wallet · convierte en CASP |
| PDF copias de documentos personales | Prohibido | PII directo |
| Documentos apostillados La Haya completos | Prohibido | Excepción solo si retención legal expresa requiere · normalmente solo hash + ref externa |
POST /kyc/startapplicant_idPOST /kyc/sumsub/webhook firmado HMAC-SHA256kyc_profiles con status + hasheskyc_attestationsexternal_reference_urlSchema completo idempotente generado en /srv/cuandeoro_kyc/schema.sql. Resumen:
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').
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.
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.
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.
{
"stellar_address": "GAB...XYZ",
"verification_level": "basic_kyc" | "enhanced_due_diligence" | "kyb_corporate",
"country_of_residence": "ES",
"enhanced_privacy": false
}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 }
{
"kyc_profile_id": "uuid",
"sumsub_token": "_act-jwt-...",
"sumsub_level": "basic-kyc-level",
"sdk_config": { ... }
}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
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)?stellar_address=GAB...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)
{
"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"
}
}{
"stellar_address": "GAB...XYZ",
"reason": "user_request" | "periodic_review" | "risk_signal" | "regulatory_inquiry"
}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 actualizadoimport 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
cuandeoro/services/sumsub_webhook con field secret. AppRole específico cuandeoro-kyc-app con scope mínimo de read solo a ese path.
-- 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
kyc_attestations.document_hash es SHA-256 (32 bytes), nunca el doc.
| Categoría | Retention | Base legal |
|---|---|---|
| kyc_profiles (active) | Mientras user activo | Ejecución del contrato Art. 6.1.b |
| kyc_profiles (deleted_at NOT NULL) | 5 años post-soft-delete | AML5 customer due diligence record keeping |
| kyc_provider_events | 5 años | AML5 + audit trail |
| kyc_attestations (verified) | 5 años post-expiry | AML5 |
| kyc_audit_log | 10 años | Defensa ante regulador + chain custody |
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
-- 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"