Afiliación de cuentas usuario integrador
Flujo de afiliación
El flujo de afiliación de cuentas es el proceso mediante el cual un usuario integrador vincula una cuenta bancaria a su cuenta SyPago a través de nuestra plataforma. La API SyPago expone endpoints para que, como integrador, puedas ofrecer esta vinculación sin que el usuario tenga que salir de tu entorno: tu sistema obtiene la información de la cuenta y del documento del usuario, la envía cifrada a SyPago y, según el banco, guia al usuario por pasos cómo validación de cuenta, generación de OTP y verificación del código, hasta que la afiliación quede completada o falle.
Requisitos previos y de seguridad
Para poder iniciar la afiliación de cuentas como usuario integrador, es necesario que se cumplan unos requisitos previos para garantizar el funcionamiento optimo del servicio.
- Autenticación: Todas las rutas requieren autorización (token JWT)La obtención del token se describe en la documentación de autenticación y gestión de API Key.
- Cifrado: Los datos sensibles de la afiliación se envían cifrados. El cliente debe:
- Obtener la llave pública del usuario.
- Generar una llave simétrica de sesión y cifrar con ella el payload de afiliación.
- Cifrar la llave simétrica con la llave pública del usuario y enviarla en POST api/v1/user/key/symetric.
Requerimientos previos, el negocio debe tener una cuenta registrada en Sypago como empresa, aquí puede revisar el módulo en el cual se explica como generar su API KEY para mas información presione aqui.
1. Intercambio de llave simétrica
Paso 1: Obtener llave pública del usuario
El cliente necesita de la llave pública del usuario para cifrar la llave simétrica que luego se enviará a POST api/v1/account/affiliate para desencriptar en el paso 2, para obtener esta llave se hará mediante GET api/v1/user/key el cual Devuelve la llave pública asimétrica del usuario autenticado.
{
"public_key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEcmdSymRdS6nLdbiP0ei7Z2DHVt+G\nlbtq3+OnHnXPtPLr/KEpSCB8wn9yak8mPwoG06Ktz1NQbJK2iaBdvCmwYQ==\n-----END PUBLIC KEY-----"
}
Método de uso
Para completar este proceso, el usuario debe estar previamente autenticado. El flujo de ejecución consiste en los siguientes pasos:
- Cifrado de Llave Simétrica: Utilice la public_key obtenida para cifrar su llave simétrica, empleando el esquema de cifrado correspondiente al backend (como ECDH o RSA).
- Envío y Aplicación: La llave simétrica generada debe enviarse al servidor durante el Paso 2. Posteriormente, esta misma llave se utilizará para cifrar el cuerpo de la solicitud en el inicio de la afiliación (Paso 4).
Paso 2: Guardar la llave simétrica
Antes de iniciar cualquier flujo, el cliente genera una llave de 32 bytes aleatorios (AES-256) y debe enviarla a SyPago de forma segura. El cliente deberá registrar esta llave simétrica (obtenida en el paso anterior) para cifrar el payload de afiliación; el servidor, por su parte, la almacena asociada al usuario y la utiliza para desencriptar el cuerpo del POST api/v1/account/affiliate, mediante el cual se recibe y persiste la llave simétrica cifrada del usuario.
POST api/v1/user/key/symetric
- Criptografía: Se utiliza ECDH (Elliptic Curve Diffie-Hellman) sobre la curva P-256. Se generan un par de llaves efímeras ECDH, posteriormente se deriva un secreto compartido entre la llave pública de SyPago y la llave privada efímera del cliente, la cual es cifrada usando AES-256-GCM.
| Payload de seguridad | Descripción |
|---|---|
| ephemeral_public_key | Base64 de la pública efímera del cliente. |
| encrypted_symmetric_key | El resultado de cifrar la llave de sesión (Ciphertext + 16 bytes de Auth Tag). |
| iv | Vector de inicialización de 12 bytes. |
Paso 3 Registro y Cifrado de Llave Simétrica en SyPago
Este proceso permite al cliente establecer un canal seguro para el intercambio de información sensible. Mediante el uso de criptografía híbrida, el cliente envía su llave simétrica (AES-256) cifrada hacia SyPago, garantizando que solo el servidor pueda conocerla. Este paso es fundamental, ya que la llave registrada será la que el sistema utilice posteriormente para procesar y persistir los datos en el flujo de afiliación.
POST api/v1/user/key/symetric
{
"ephemeral_public_key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEz5yF0JaUxdJK1EvEsfH5L3627xD/Y95GKOofvevJe93slRzqQ/FfzrjDEshncoH6hK31PpygS5fHEiCv1FK0hg==",
"encrypted_symmetric_key": "D3qAu7MR6b3H3gfwb7K7P97Gw5GfseMBSPQASWL/Y8TnN7gXP4YxvjejJG1pAUIt",
"iv": "DpLjLxz8cIbGl7HC"
}
Una vez recibidos los datos, SyPago utiliza su llave privada junto con la ephemeral_public_key del cliente para derivar el mismo secreto compartido. Con este secreto, se desencripta la llave simétrica, se verifica la integridad mediante el tag GCM y, finalmente, se almacena la llave asociada al usuario para su uso exclusivo en el paso de afiliación.
2. Endpoints de bancos y filtro por afiliación
1.Listado de Bancos
Su propósito principal es permitir que el cliente consulte dinámicamente las entidades disponibles y determine, mediante sus atributos, qué capacidades operativas tiene cada banco (como la disponibilidad del flujo de afiliación.
- Autenticación: No requerida (se aplica únicamente rate limit por dirección IP).
- Respuesta: Una lista de objetos con la estructura de la Institución Bancaria (IBP).
GET api/v1/banks
{
"Code": "0001",
"Name": "BCV",
"Active": true,
"SypagoClient": true,
"HasAccountLinkingFlow": true,
...
}
Filtro para afiliación: El cliente debe usar solo bancos donde HasAccountLinkingFlow sea true. Solo esos bancos tienen flujo de vinculación de cuentas en la API.
2.Búsqueda del flujo de afiliación del banco (antes de iniciar el flujo)
Antes de iniciar el proceso de afiliación, es indispensable consultar el flujo específico del banco seleccionado. Esto permite identificar los pasos requeridos, los datos necesarios para cada etapa y las acciones disponibles (como el reenvío de OTP), facilitando así el diseño de la interfaz de usuario y la correcta implementación de las llamadas al API.
GET api/v1/bank/affiliate/flow
Query params (obligatorios):
| Parámetro | Tipo | Requerido | Descripción |
|---|---|---|---|
| bank_code | string | Código del banco de 4 dígitos (ej. 0172). | Debe coincidir con un banco de la lista GET api/v1/banks que tenga hasAccountLinkingFlow: true. |
| acct_type | string | Tipo de cuenta. Debe ser exactamente CNTA (cuenta bancaria). | Cualquier otro valor devuelve 400. |
GET /api/v1/bank/affiliate/flow?bank_code=0001&acct_type=CNTA
Authorization: Bearer <token>
| Codigos HTTP | Descripción |
|---|---|
| 400 | Si bank_code falta o está vacío, con mensaje indicando que bank_code es requerido |
| 400 | Si bank_code no son 4 dígitos ("bank_code debe ser un código de 4 dígitos"). |
| 400 | Si acct_type falta o está vacío ("acct_type es requerido en query param") |
| 400 | Si acct_type no es CNTA, si no hay flujo configurado para ese bank_code y tipo de cuenta respuesta de error según implementación |
| 200 | OK con un JSON que describe el flujo |
El body es un objeto BankAffiliationFlow con la siguiente estructura:
Response
{
"bank_code": "0001",
"flow_type": "MULTI_STEP",
"account_type": "CNTA",
"initial_requirements": {
"standart_required_fields": ["account", "document_info"],
"metadata_fields": []
},
"steps": [
{
"step_name": "validate_account",
"required_data": [],
"message": "Cuenta validada.",
"available_actions": []
},
{
"step_name": "generate_otp",
"required_data": [],
"message": "Se ha enviado un OTP.",
"available_actions": []
},
{
"step_name": "verify_otp",
"required_data": [
{ "name": "otp", "type": "string" }
],
"message": "Ingrese el OTP.",
"available_actions": [
{
"action": "resend_otp",
"required_data": [],
"link": "...",
"method": "POST",
"cooldown_seconds": 60
}
]
}
]
}
3. Inicio del flujo de afiliación
Tras obtener el listado de bancos mediante hasAccountLinkingFlow y validar el flujo del banco seleccionado, el cliente puede iniciar la afiliación enviando el payload cifrado correspondiente.
Este endpoint es el punto de entrada principal para el proceso de vinculación de cuentas. Su función es recibir y procesar la información de afiliación del cliente de manera segura. Para garantizar la protección de datos sensibles, el endpoint no acepta información en texto plano; en su lugar, requiere un payload cifrado mediante el esquema de seguridad previamente establecido. Al recibir la solicitud, el servidor valida los permisos, descifra el contenido y comienza la orquestación del flujo de afiliación según las reglas del banco seleccionado.
- URL: POST /api/v1/account/affiliate
- Autenticación: Requerida. El cliente debe contar con el permiso específico ApiKeyAffiliationAction.
- Cuerpo de la Solicitud (Body): Objeto JSON que contiene el payload cifrado.
POST /api/v1/account/affiliate
Paso 1 Cifrado del payload de afiliación
Este apartado describe el procedimiento de seguridad necesario para el envío de datos sensibles durante el inicio del flujo de afiliación. Para asegurar la confidencialidad e integridad de la información, el cliente debe aplicar un cifrado simétrico sobre el contenido del mensaje antes de consumirlo a través de la API. Este mecanismo garantiza que solo el servidor, poseedor de la llave previamente registrada, pueda acceder al contenido original del JSON de afiliación.
Para realizar la petición al endpoint POST api/v1/account/affiliate, el cliente debe seguir estos pasos:
- 1. Construcción del Mensaje: Generar el JSON de afiliación en texto plano.
- 2. Aplicación de AES-256-GCM: Cifrar el JSON utilizando la llave simétrica de 32 bytes que se registró en pasos anteriores, bajo los siguientes parámetros:
- IV/Nonce: 12 bytes aleatorios (debe ser único para cada solicitud).
- Tag: 16 bytes (generado por el algoritmo AES-GCM).
{
"ciphertext": "<Base64 del ciphertext>",
"iv": "<Base64 del IV de 12 bytes>",
"tag": "<Base64 del tag de 16 bytes>"
}
Paso 2 afiliación de cuenta
Estructura que el cliente debe armar y luego de realizar el cifrado:
POST api/v1/account/affiliate
{
"document_info": {
"type": "V",
"number": "12345678"
},
"account": {
"bank_code": "0172",
"type": "CNTA",
"number": "01720010174520100126130"
},
"alias": "Mi cuenta principal",
"metadata": {}
}
Cargando datos...
{
"flow_id": "74B472DB3DA9",
"status": "CHALLENGE_REQUIRED",
"next_step": {
"step_name": "verify_otp",
"required_data": [
{
"name": "otp_code",
"type": "string",
"required": true,
"description": "Código OTP enviado al usuario"
}
],
"message": "Verificando OTP",
"available_actions": [
{
"action": "resend_otp",
"description": "Reenviar el código OTP al usuario. Solo se puede solicitar cada 30 segundos.",
"cooldown_seconds": 30,
"link": "https://desa.sypago.net/api/v1/account/affiliate/74B472DB3DA9/action/resend_otp",
"method": "POST"
}
],
"link": "https://desa.sypago.net/api/v1/account/affiliate/74B472DB3DA9/verify_otp",
"method": "PUT",
"cooldown_seconds": 30
}
}
| Código | Mensaje | Descripción |
|---|---|---|
| 201 | Created | Cuando el flujo se inicia correctamente y no hay error de negocio. |
| 422 | Unprocessable Entity | Cuando hay error de negocio cuenta ya registrada, validación fallida, etc. |
| Estado | Descripción |
|---|---|
| PENDING | Operación pendiente |
| CHALLENGE_REQUIRED | Hay que ejecutar el paso indicado en next_step |
| COMPLETED | Afiliación completada |
| FAILED | Error (revisar error). |
| EXPIRED | Tiempo del flujo agotado. |
GET /api/v1/account/affiliate/{flowId}
Este endpoint actúa como un mecanismo de recuperación de contexto dentro del proceso de afiliación. Su función principal es permitir al cliente identificar el paso exacto en el que quedó el flujo (next_step), sin necesidad de ejecutar una acción o avanzar forzosamente en la secuencia.
PUT api/v1/account/affiliate/{flowId}/{stepName}
Este recurso permite la ejecución de las etapas lógicas requeridas para completar afiliación dado que cada institución financiera posee requisitos de seguridad distintos, el flujo puede estar compuesto por múltiples pasos o, en algunos casos, completarse de forma atómica sin necesidad de pasos adicionales. Este endpoint es el encargado de procesar secuencialmente cada requerimiento conforme el banco lo solicite en su configuración dinámica.
{
"otp": "123456"
}
Si el banco no requiere pasos adicionales tras la ejecución de este endpoint (o si el flujo era de un solo paso desde el inicio), el objeto de respuesta incluirá el account_id generado y el estado COMPLETED.
Ejecutar una acción
POST api/v1/account/affiliate/{flowId}/action/{actionName}
- URL: POST /api/v1/account/affiliate/{flowId}/action/{actionName}
- Ejemplo: POST /api/v1/account/affiliate/abc123xyz/action/resend_otp
- Autenticación: requerida; permiso ApiKeyAffiliationAction.
- Body: JSON opcional con los datos requeridos por la acción (según next_step.available_actions[].required_data). Puede ser {} si no hay datos.
| Código | Mensaje | Descripción |
|---|---|---|
| 200 | OK | Cuando el flujo se inicia correctamente y no hay error de negocio. |
| 422 | Acción no disponible | Step incorrecto, cooldown, etc |
Códigos de error típicos(AffiliationError)
| Code | Significado | can_retry |
|---|---|---|
| ACCOUNT_ALREADY_REGISTERED | La cuenta ya está registrada para otro usuario. | false |
| ACTION_COOLDOWN | Esperar X segundos antes de reintentar. | true |
| WRONG_STEP | El step o acción no coincide con el esperado. | false |
| METADATA_BAD_FORMAT | Metadata del step/acción inválida. | true |
| FLOW_EXPIRED | El proceso de afiliación expiró. | false |
| CODE_FLOW_IS_ALREDY_PROCESSED | El flujo ya fue completado o finalizado. | false |
| MAX_ATTEMPTS_REACHED | Máximo de intentos alcanzado. | false |
Flujo completo(Recomendado)
1-Llaves (una vez por usuario)
- GET api/v1/user/key → obtener llave pública del usuario.
- Generar llave simétrica y cifrarla (ECDH P-256 + AES-256-GCM) como en la sección 2.2.
- POST api/v1/user/key/symetric con el JSON de llave cifrada.
2-Bancos y flujo
- GET api/v1/banks → filtrar por hasAccountLinkingFlow === true.
- GET api/v1/bank/affiliate/flow?bank_code=0172&acct_type=CNTA → obtener pasos y acciones.
3-Iniciar afiliación
- Armar JSON de afiliación (document_info, account, alias, metadata).
- Cifrarlo con AES-256-GCM (IV 12 bytes, tag 16 bytes) usando la llave simétrica.
- POST api/v1/account/affiliate con { "ciphertext", "iv", "tag" } en Base64.
4-Avanzar el flujo
- Según next_step:
- Llamar PUT .../affiliate/{flowId}/{stepName} con el body indicado en required_data. O POST .../affiliate/{flowId}/action/{actionName} si se trata de una acción (ej. resend_otp).
- Repetir hasta status: COMPLETED o status: FAILED/EXPIRED.
5-Opcional
- GET api/v1/account/affiliate/{flowId} para consultar estado sin ejecutar un paso.
GO
Uso en el flujo (Go): Obtener publicKeyPEM de GET api/v1/user/key (si la API devuelve Base64 de SPKI, envolver en PEM o decodificar con x509.ParsePKIXPublicKey). Luego payload, keyB64, err := GenerateAndEncryptSymmetricKey(publicKeyPEM) → enviar payload en POST api/v1/user/key/symetric. Para iniciar afiliación: affiliationJSON := ... → enc, _ := Encrypt(affiliationJSON, keyB64) → enviar enc en POST api/v1/account/affiliate.
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/ecdsa"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"io"
)
// SecureKeyPayload es el cuerpo de POST api/v1/user/key/symetric
type SecureKeyPayload struct {
EphemeralPublicKey string `json:"ephemeral_public_key"`
EncryptedSymmetricKey string `json:"encrypted_symmetric_key"`
Iv string `json:"iv"`
}
// EncryptedDataPayload es el cuerpo de POST api/v1/account/affiliate
type EncryptedDataPayload struct {
Ciphertext string `json:"ciphertext"`
Iv string `json:"iv"`
Tag string `json:"tag"`
}
func GenerateSymmetricKey() ([]byte, error) {
key := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return nil, err
}
return key, nil
}
func GenerateAndEncryptSymmetricKey(serverPubKeyPEM string) (*SecureKeyPayload, string, error) {
block, _ := pem.Decode([]byte(serverPubKeyPEM))
if block == nil {
return nil, "", fmt.Errorf("no se pudo decodificar el bloque PEM")
}
genericPubKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, "", fmt.Errorf("error al parsear llave pública: %v", err)
}
ecdsaPubKey, ok := genericPubKey.(*ecdsa.PublicKey)
if !ok {
return nil, "", fmt.Errorf("la llave pública no es de tipo ECDSA")
}
serverECDHKey, err := ecdsaPubKey.ECDH()
if err != nil {
return nil, "", fmt.Errorf("error al convertir a ECDH: %v", err)
}
symmetricKey, err := GenerateSymmetricKey()
if err != nil {
return nil, "", err
}
privEphemeral, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
return nil, "", err
}
sharedSecret, err := privEphemeral.ECDH(serverECDHKey)
if err != nil {
return nil, "", err
}
blockCipher, err := aes.NewCipher(sharedSecret)
if err != nil {
return nil, "", err
}
gcm, _ := cipher.NewGCM(blockCipher)
iv := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
return nil, "", err
}
encryptedSymmetricKey := gcm.Seal(nil, iv, symmetricKey, nil)
ephemeralPubKeyBytes, _ := x509.MarshalPKIXPublicKey(privEphemeral.PublicKey())
return &SecureKeyPayload{
EphemeralPublicKey: base64.StdEncoding.EncodeToString(ephemeralPubKeyBytes),
EncryptedSymmetricKey: base64.StdEncoding.EncodeToString(encryptedSymmetricKey),
Iv: base64.StdEncoding.EncodeToString(iv),
}, base64.StdEncoding.EncodeToString(symmetricKey), nil
}
func Encrypt(plainText string, keyB64 string) (*EncryptedDataPayload, error) {
key, _ := base64.StdEncoding.DecodeString(keyB64)
block, _ := aes.NewCipher(key)
gcm, _ := cipher.NewGCM(block)
iv := make([]byte, gcm.NonceSize())
io.ReadFull(rand.Reader, iv)
fullCiphertext := gcm.Seal(nil, iv, []byte(plainText), nil)
tagSize := gcm.Overhead()
ciphertext := fullCiphertext[:len(fullCiphertext)-tagSize]
tag := fullCiphertext[len(fullCiphertext)-tagSize:]
return &EncryptedDataPayload{
Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
Iv: base64.StdEncoding.EncodeToString(iv),
Tag: base64.StdEncoding.EncodeToString(tag),
}, nil
}
func Decrypt(payload EncryptedDataPayload, keyB64 string) (string, error) {
key, _ := base64.StdEncoding.DecodeString(keyB64)
iv, _ := base64.StdEncoding.DecodeString(payload.Iv)
ciphertext, _ := base64.StdEncoding.DecodeString(payload.Ciphertext)
tag, _ := base64.StdEncoding.DecodeString(payload.Tag)
block, _ := aes.NewCipher(key)
gcm, _ := cipher.NewGCM(block)
combined := append(ciphertext, tag...)
plainBytes, err := gcm.Open(nil, iv, combined, nil)
if err != nil {
return "", err
}
return string(plainBytes), nil
}
C#
Uso en el flujo (C#): Obtener la llave pública (PEM) de GET api/v1/user/key. Luego var (payload, rawKeyB64) = cryptoService.GenerateAndEncryptSymmetricKey(serverPubKeyPem) → enviar payload en POST api/v1/user/key/symetric. Para iniciar afiliación: var plainJson = JsonSerializer.Serialize(affiliationData) → var encrypted = cryptoService.EncryptWithSymmetric(plainJson, rawKeyB64) → enviar encrypted en POST api/v1/account/affiliate.
using System.Security.Cryptography;
using System.Text;
public class CryptoService
{
public (byte[] keyBytes, string keyB64) GenerateSymmetricKey()
{
byte[] key = new byte[32];
RandomNumberGenerator.Fill(key);
return (key, Convert.ToBase64String(key));
}
public (SecureKeyPayload payload, string rawKeyB64) GenerateAndEncryptSymmetricKey(string serverPubKeyPem)
{
var (symmetricKey, keyB64) = GenerateSymmetricKey();
using ECDiffieHellman serverPubKey = ECDiffieHellman.Create();
serverPubKey.ImportFromPem(serverPubKeyPem);
using ECDiffieHellman alice = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
byte[] sharedSecret = alice.DeriveRawSecretAgreement(serverPubKey.PublicKey);
byte[] iv = new byte[12];
RandomNumberGenerator.Fill(iv);
byte[] ciphertext = new byte[symmetricKey.Length];
byte[] tag = new byte[16];
using (var aes = new AesGcm(sharedSecret.AsSpan(0, 32), tagSizeInBytes: 16))
{
aes.Encrypt(iv, symmetricKey, ciphertext, tag);
}
byte[] combinedContent = new byte[ciphertext.Length + tag.Length];
ciphertext.CopyTo(combinedContent.AsSpan(0, ciphertext.Length));
tag.CopyTo(combinedContent.AsSpan(ciphertext.Length, tag.Length));
var payload = new SecureKeyPayload
{
EphemeralPublicKey = Convert.ToBase64String(alice.ExportSubjectPublicKeyInfo()),
EncryptedSymmetricKey = Convert.ToBase64String(combinedContent),
Iv = Convert.ToBase64String(iv)
};
return (payload, keyB64);
}
public EncryptedDataPayload EncryptWithSymmetric(string plainText, string keyB64)
{
byte[] key = Convert.FromBase64String(keyB64);
byte[] plainBytes = Encoding.UTF8.GetBytes(plainText);
byte[] iv = new byte[12];
RandomNumberGenerator.Fill(iv);
byte[] ciphertext = new byte[plainBytes.Length];
byte[] tag = new byte[16];
using (var aes = new AesGcm(key, tagSizeInBytes: 16))
{
aes.Encrypt(iv, plainBytes, ciphertext, tag);
}
return new EncryptedDataPayload
{
Ciphertext = Convert.ToBase64String(ciphertext),
Iv = Convert.ToBase64String(iv),
Tag = Convert.ToBase64String(tag)
};
}
public string DecryptWithSymmetric(EncryptedDataPayload payload, string keyB64)
{
byte[] key = Convert.FromBase64String(keyB64);
byte[] iv = Convert.FromBase64String(payload.Iv);
byte[] tag = Convert.FromBase64String(payload.Tag);
byte[] ciphertext = Convert.FromBase64String(payload.Ciphertext);
byte[] decryptedBytes = new byte[ciphertext.Length];
using (var aes = new AesGcm(key, tagSizeInBytes: 16))
{
aes.Decrypt(iv, ciphertext, tag, decryptedBytes);
}
return Encoding.UTF8.GetString(decryptedBytes);
}
}
// Modelos para el cuerpo de las peticiones
public class EncryptedDataPayload
{
public string Ciphertext { get; set; } = string.Empty;
public string Iv { get; set; } = string.Empty;
public string Tag { get; set; } = string.Empty;
}
public class SecureKeyPayload
{
public string EphemeralPublicKey { get; set; } = string.Empty;
public string EncryptedSymmetricKey { get; set; } = string.Empty;
public string Iv { get; set; } = string.Empty;
}
Python
Uso en el flujo (Python): Obtener public_key de GET api/v1/user/key. Luego payload, key_b64 = generate_and_encrypt_symmetric_key(public_key) → enviar payload en POST api/v1/user/key/symetric. Para iniciar afiliación: affiliation_json = json.dumps({"document_info": {...}, "account": {...}, ...}) → encrypted = encrypt(affiliation_json, key_b64) → enviar encrypted en POST api/v1/account/affiliate.
import base64
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.backends import default_backend
# --- SecureKeyPayload: cuerpo de POST api/v1/user/key/symetric ---
# --- EncryptedDataPayload: cuerpo de POST api/v1/account/affiliate ---
def generate_symmetric_key():
"""Genera llave simétrica de 32 bytes. Retorna (bytes, base64)."""
key = os.urandom(32)
return key, base64.b64encode(key).decode("ascii")
def _load_server_public_key(server_key: str):
"""Carga llave pública del servidor: PEM o Base64 de SPKI DER."""
if "BEGIN" in server_key or "-----" in server_key:
return serialization.load_pem_public_key(server_key.encode(), backend=default_backend())
raw = base64.b64decode(server_key)
return serialization.load_der_public_key(raw, backend=default_backend())
def generate_and_encrypt_symmetric_key(server_public_key_pem_or_b64: str) -> tuple[dict, str]:
"""
Genera llave simétrica, la cifra con la llave pública del servidor (ECDH P-256 + AES-GCM)
y retorna (SecureKeyPayload para POST api/v1/user/key/symetric, raw_key_b64).
"""
server_pub = _load_server_public_key(server_public_key_pem_or_b64)
sym_key, key_b64 = generate_symmetric_key()
ephemeral_private = ec.generate_private_key(ec.SECP256R1(), default_backend())
shared_secret = ephemeral_private.exchange(ec.ECDH(), server_pub)
aes_key = shared_secret[:32]
iv = os.urandom(12)
aesgcm = AESGCM(aes_key)
encrypted_sym = aesgcm.encrypt(iv, sym_key, None)
ciphertext_plus_tag = encrypted_sym
ephemeral_pub_bytes = ephemeral_private.public_key().public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
payload = {
"ephemeral_public_key": base64.b64encode(ephemeral_pub_bytes).decode("ascii"),
"encrypted_symmetric_key": base64.b64encode(ciphertext_plus_tag).decode("ascii"),
"iv": base64.b64encode(iv).decode("ascii"),
}
return payload, key_b64
def encrypt(plain_text: str, key_b64: str) -> dict:
"""Cifra con la llave simétrica. Retorna EncryptedDataPayload para POST api/v1/account/affiliate."""
key = base64.b64decode(key_b64)
plain_bytes = plain_text.encode("utf-8")
iv = os.urandom(12)
aesgcm = AESGCM(key)
ciphertext_with_tag = aesgcm.encrypt(iv, plain_bytes, None)
tag_len = 16
ciphertext = ciphertext_with_tag[:-tag_len]
tag = ciphertext_with_tag[-tag_len:]
return {
"ciphertext": base64.b64encode(ciphertext).decode("ascii"),
"iv": base64.b64encode(iv).decode("ascii"),
"tag": base64.b64encode(tag).decode("ascii"),
}
def decrypt(payload: dict, key_b64: str) -> str:
"""Descifra un EncryptedDataPayload con la llave simétrica en Base64."""
key = base64.b64decode(key_b64)
iv = base64.b64decode(payload["iv"])
ciphertext = base64.b64decode(payload["ciphertext"])
tag = base64.b64decode(payload["tag"])
aesgcm = AESGCM(key)
plain_bytes = aesgcm.decrypt(iv, ciphertext + tag, None)
return plain_bytes.decode("utf-8")
JavaScript (Node.js)
const crypto = require('crypto');
/**
* Genera llave simétrica de 32 bytes. Retorna { keyBytes, keyB64 }.
*/
function generateSymmetricKey() {
const key = crypto.randomBytes(32);
return { keyBytes: key, keyB64: key.toString('base64') };
}
/**
* Carga la llave pública del servidor: PEM o Base64 de SPKI DER.
*/
function loadServerPublicKey(serverKeyPemOrBase64) {
const isPem = typeof serverKeyPemOrBase64 === 'string' &&
(serverKeyPemOrBase64.includes('BEGIN') || serverKeyPemOrBase64.includes('-----'));
if (isPem) {
return crypto.createPublicKey({ key: serverKeyPemOrBase64, format: 'pem' });
}
const der = Buffer.from(serverKeyPemOrBase64, 'base64');
return crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
}
/**
* Genera llave simétrica, la cifra con la llave pública del servidor (ECDH P-256 + AES-GCM)
* y retorna { payload, rawKeyB64 }. payload es el cuerpo de POST api/v1/user/key/symetric.
*/
function generateAndEncryptSymmetricKey(serverPublicKeyPemOrBase64) {
const serverPub = loadServerPublicKey(serverPublicKeyPemOrBase64);
const { keyBytes: symKey, keyB64 } = generateSymmetricKey();
const { publicKey: ephemeralPub, privateKey: ephemeralPriv } = crypto.generateKeyPairSync('ec', {
namedCurve: 'P-256',
});
const sharedSecret = crypto.diffieHellman({ privateKey: ephemeralPriv, publicKey: serverPub });
const aesKey = sharedSecret.slice(0, 32);
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv, { authTagLength: 16 });
const enc = Buffer.concat([cipher.update(symKey), cipher.final()]);
const tag = cipher.getAuthTag();
const encryptedSymmetricKey = Buffer.concat([enc, tag]);
const ephemeralPublicKeyB64 = ephemeralPub.export({ type: 'spki', format: 'der' }).toString('base64');
const payload = {
ephemeral_public_key: ephemeralPublicKeyB64,
encrypted_symmetric_key: encryptedSymmetricKey.toString('base64'),
iv: iv.toString('base64'),
};
return { payload, rawKeyB64: keyB64 };
}
/**
* Cifra plainText con la llave simétrica. Retorna EncryptedDataPayload para POST api/v1/account/affiliate.
*/
function encrypt(plainText, keyB64) {
const key = Buffer.from(keyB64, 'base64');
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 });
const enc = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return {
ciphertext: enc.toString('base64'),
iv: iv.toString('base64'),
tag: tag.toString('base64'),
};
}
/**
* Descifra un EncryptedDataPayload con la llave en Base64.
*/
function decrypt(payload, keyB64) {
const key = Buffer.from(keyB64, 'base64');
const iv = Buffer.from(payload.iv, 'base64');
const ciphertext = Buffer.from(payload.ciphertext, 'base64');
const tag = Buffer.from(payload.tag, 'base64');
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 });
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
}
Uso en el flujo (Node.js): Obtener public_key de GET api/v1/user/key. Luego const { payload, rawKeyB64 } = generateAndEncryptSymmetricKey(public_key) → enviar payload en POST api/v1/user/key/symetric. Para iniciar afiliación: const affiliationJson = JSON.stringify({ document_info: {...}, account: {...}, ... }) → const encrypted = encrypt(affiliationJson, rawKeyB64) → enviar encrypted en POST api/v1/account/affiliate.