Firmar Mensajes

Cómo firmar mensajes shinkansen con JWS

Firmar Mensajes

La mayoría de los mensajes que son enviados por un Participante hacia Shinkansen deben ser firmados usando JWS (JSON Web Signature). Para que el API sea legible, Shinkansen usa detached signatures, lo que significa que el contenido a firmar es el cuerpo HTTP de los mensajes que se envían en Shinkansen mediante peticiones POST. La firma se envía en una cabecera HTTP Shinkansen-JWS-Signature.

Algoritmo

Para firmar mensajes, usamos el algoritmo PS256 de JWA, que usa RSA (ampliamente usado y con soporte en variedad de plataformas y arquitecturas) en una versión moderna y más segura que lo ofrecido por el algoritmo RS256 de JWA.

👍

En la práctica:

En la mayoría de las librerías JWS deberás pasar el valor "PS256" en el parámetro/cabecera "alg" a la hora de generar la firma.

Certificado(s)

El uso de RSA requiere que cada Participante tenga una llave privada para firmar los mensajes y sus contrapartes tengan las llaves públicas correspondientes. Para facilitar el manejo seguro de estas llaves públicas, las asociamos a un certificado obtenido en un PSC o CA según lo descrito en el modelo de seguridad.

Para firmar un mensaje deberás usar la llave privada y enviar el certificado con la llave púbica.

👍

En la práctica:

Deberás incluir el certificado DER encodeado en base64 en el parámetro/cabecera "x5c" de JWS a la hora de generar la firma.

Esto es muy cercano al encoding PEM de certificados que quizás has visto. Son mas o menos así:

-----BEGIN CERTIFICATE-----
MIIGAjCCBOqgAwIBAgIIREUj+lcjuVcwDQYJKoZIhvcNAQELBQAwgbcxHjAcBgkq
hkiG9w0BCQEWD3NvcG9ydGVAaWRvay5jbDEfMB0GA1UEAwwWSURPSyBGSVJNQSBF
TEVDVFJPTklDQTEXMBUGA1UECwwOUlVULTc2NjEwNzE4LTQxIDAeBgNVBAsMF0F1
dG9yaWRhZCBDZXJ0aWZpY2Fkb3JhMRkwFwYDVQQKDBBCUE8gQWR2aXNvcnMgU3BB
MREwDwYDVQQHDAhTYW50aWFnbzELMAkGA1UEBhMCQ0wwHhcNMjAwODEyMjIwNjIx
WhcNMjMwNTI4MTMwNzMwWjB6MSYwJAYDVQQDDB1MRU9OQVJETyBIVU1CRVJUTyBT
T1RPIE1Vw5FPWjEhMB8GCSqGSIb3DQEJARYSbGVvLnNvdG9AZ21haWwuY29tMRMw
EQYDVQQFEwoxNTYyMDMxOC0xMQswCQYDVQQGEwJDTDELMAkGA1UEBwwCUk0wggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDoRiVZ2Ya1JpcaIDJi3xLH8g0v
MF6XzvQONdTYZRnPXui3fk3+LM2YZ9w9xdck38H0i9Qp/aRAKvxmgPIGQuAWZLJy
9HujCCKH1EY8HWEcWCHPu2zy4puJV3jBB/mhkHvKbiBsriCVOFvHCQlcJOytQyOb
AtGbl2dMNzb2w5cavRnPkNaWQGo3BLY1gsoXTsKBGF2rDPmOPipEGcz9QHntz8qP
JLrYD2GMXZIwtjGzHP1+K2eP7NHyoTTApMOaDBkqfRmyXJ84goc6jyHCSuLyQJsl
2+A6nxctyENA9Hh4EAKLUU1E81rz0ovqrAxdYAi8Gg8MnF9cwI8MaLlEG1C7AgMB
AAGjggJMMIICSDAJBgNVHRMEAjAAMB8GA1UdIwQYMBaAFPBsM7+sl5NYeqHgzp7s
6N77ZT76MIGYBgNVHSAEgZAwgY0wgYoGCisGAQQBg4weAQQwfDAsBggrBgEFBQcC
ARYgaHR0cHM6Ly9wc2MuaWRvay5jbC9vcGVuL2Nwcy5wZGYwTAYIKwYBBQUHAgIw
QB4+AEMAZQByAHQAaQBmAGkAYwBhAGQAbwAgAHAAYQByAGEAIAB1AHMAbwAgAFQA
cgBpAGIAdQB0AGEAcgBpAG8wggEHBgNVHR8Egf8wgfwwgfmgN6A1hjNodHRwczov
L3BzYy5pZG9rLmNsL29wZW4vSURPSyBGSVJNQSBFTEVDVFJPTklDQS5jcmyigb2k
gbowgbcxHjAcBgkqhkiG9w0BCQEWD3NvcG9ydGVAaWRvay5jbDEfMB0GA1UEAwwW
SURPSyBGSVJNQSBFTEVDVFJPTklDQTEXMBUGA1UECwwOUlVULTc2NjEwNzE4LTQx
IDAeBgNVBAsMF0F1dG9yaWRhZCBDZXJ0aWZpY2Fkb3JhMRkwFwYDVQQKDBBCUE8g
QWR2aXNvcnMgU3BBMREwDwYDVQQHDAhTYW50aWFnbzELMAkGA1UEBhMCQ0wwHQYD
VR0OBBYEFFh0yhCCXYJ7OUn2dAPaZUOjfT0iMAsGA1UdDwQEAwIEkDAjBgNVHRIE
HDAaoBgGCCsGAQQBwQECoAwWCjc2NjEwNzE4LTQwIwYDVR0RBBwwGqAYBggrBgEE
AcEBAaAMFgoxNTYyMDMxOC0xMA0GCSqGSIb3DQEBCwUAA4IBAQBxPD7ETW2rK/zH
WEzMM0HKirpqU4Hf8GIErjHHHXukeUjPidNjnWq7aGGAW5sLyYPDhuFSEF/YBSau
9m6fuerwisFFd9n1hjjAQPmKb0MltajPXH9vdIXk4+A7g1W5Stbq1Ezt8E32+Zv9
17l36d33P8wwjkkPkmW14LSm9GwLvwULNkZrocgb4o8oIIzhqQSs/f+qo9rezs6S
WMmbbubDDdOm+HRBao2yjbKilFcMiIA35AJPVUZdSUc/6MzyoA0n0KePXnFQ93HR
S1BRayUnRU+8sNvKVrI7gaSVckYP1xO+747mDzyAYHYAAkE9LI1uA3VwRj5lWuEq
i4eytLag
-----END CERTIFICATE-----

Eso ya está base64-encodeado, pero con saltos de línea cada 64 caracteres y con delimitadores. Este mismo certificado en una cabecera JWS se ve así:

x5c: [
  "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",
];

Otros parámetros JWS

Para mayor eficiencia (y claridad), evitamos encodear en base64 el mensaje JSON a firmar. Esto implica usar la extensión b64 de JWS, que es parte del RFC 7797.

👍

En la práctica:

En la mayoría de las librerías JWS deberás pasar el valor false en el parámetro "b64" a la hora de generar la firma. Es posible que también debas pasar explícitamente el parámetro "crit" con valor ["b64"].

Generar la firma

Si la librería que usas no tiene soporte nativo para generar detached signatures, deberás generar una representación JSON del JWS (con los campos protected, payload, y signature). Luego deberás concatenar el valor de protected con ".." y luego signature (el payload no se incluye).

Ejemplos

En la práctica esto es bastante mecánico usando librerías existentes. Puedes ver el ejemplo mas abajo en Python como referencia.

O aún mejor, puede resultar en muy pocas líneas usando librerías/wrappers que hemos creado, como el ejemplo Python que usa python-shinkansen más abajo.

from shinkansen import jws
from shinkansen.payouts import (
    PayoutMessage,
    PayoutMessageHeader,
    PayoutTransaction,
    SHINKANSEN,
    CLP,
    FinancialInstitution
)
from shinkansen.payouts import PayoutMessage

# Load RSA key and certificate from file system, password from env var.
private_key = jws.private_key_from_pem_file("/path/to/privatekey.pem",
  password=os.getenv('PRIVATE_KEY_PASSWORD'))
public_cert = jws.certificate_from_pem_file("/path/to/certificate.pem")
# You can also use jws.private_key_from_pem_bytes() and
# jws.certificate_from_pem_bytes() if you prefer to load everything from env
# vars or somewhere else.

# Have an API Key:
api_key = os.getenv('SHINKANSEN_API_KEY')

# Build a message to sign...
shinkansen_message = PayoutMessage(
  header=PayoutMessageHeader(
    sender=FinancialInstitution("<MY-ID-AS-SENDER>")
    receiver=SHINKANSEN
    #...
  ),
  transactions=[
    PayoutTransaction(
      currency=CLP,
      amount="1000",
      # ...
    )
  ]
)

# Sign and send the message:
signature, response = shinkansen_message.sign_and_send(
  private_key, public_cert, api_key,
  # base_url="https://dev.shinkansen.finance/v1" # to use dev environment
)

# That's it. You can save the signature if you want. And read the
# response (instance of `PayoutHttpResponse`) containing `http_status_code`,
# `transaction_ids` (a dict mapping your ids to shinkansen ids) and `errors`
# (a list of `PayoutHttpResponseError` containing `error_code` and
# `error_message`)
from jwcrypto.jwk import JWK
from jwcrypto.jws import JWS
from base64 import b64encode, b64decode
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography import x509
import requests
import json

# A helper function
def file_content(path):
  with open(path, "rb") as f:
      return f.read()

# The message to sign...
shinkansen_message_body = {"document": {"header" : {
   # ... (the rest of the message body which needs to be signed)
}}}

# ...serialized as a JSON-encoded string. Since we are signing this string, you
# MUST send this exact string payload on the HTTP request (if you pass the
# original shinkansen_message_body dict it may be re-encoded on a slightly
# different way and that will make the signature invalid.
payload = json.dumps(shinkansen_message_body)

# Load RSA key and certificate from file system, password from env var
key = serialization.load_pem_private_key(
  file_content("/path/to/privatekey.pem"),
  password=os.environ['PRIVATE_KEY_PASSWORD']
)
certificate = x509.load_pem_x509_certificate(
    file_content("/path/to/certificate.pem"),
)

# Compute x5c header, must go in base64 DER format:
certificate_der_b64 = b64encode(
  certificate.public_bytes(encoding=serialization.Encoding.DER)
).decode('ascii')

# Build the JWK with our key for signing
jwk = JWK()
jwk.import_from_pyca(key)

# Build the JWS
jws = JWS(payload) # with our payload
jws.add_signature( # and sign, using the header required by Shinkansen:
    jwk,
    protected={
        "alg": "PS256",               # PS256 algorithm
        "b64": False,                 # The b64 = false header
        "crit": ["b64"],              # Required for b64 header
        "x5c": [certificate_der_b64], # The x509 header
    },
)
# Now we get the JWS in JSON format...
json_jws = jws.serialize()
# ...parse it
parsed_jws = json.loads(json_jws)
# ...and extract only the protected header and signature, to build the compact
# detached representation
jws_signature_header = f"{parsed_jws['protected']}..{parsed_jws['signature']}"

# And we are done!
requests.post(".../messages/move", data=payload,
  headers={
    'Content-Type': 'application/json',
    'Shinkansen-JWS-Signature': jws_signature_header,
    'Shinkansen-API-Key': os.environ['SHINKANSEN_API_KEY'])
  }
)
import * as fs from 'fs';
import axios from 'axios';
import * as jose from 'jose'
import { X509Certificate } from 'crypto';

// The message to sign...
const shinkansen_message_body = JSON.parse("{"document":{ "header" : {" +
  "# ... (the rest of the message body which needs to be signed)}}}")

// ...serialized as a JSON-encoded string. Since we are signing this string,
// you MUST send this exact string payload on the HTTP request
// (if you pass the original shinkansen_message_body dict it may be re-encoded
// on a slightly different way and that will make the signature invalid.
const payload = JSON.stringify(shinkansen_message_body)

// Load RSA key and certificate from file system,
const certificate = (new X509Certificate(fs.readFileSync('./cert.pem')))

const ALGORITHM = 'PS256'
const privateKey = await jose.importPKCS8(fs.readFileSync('./key.pem').toString(), ALGORITHM)

// x5c header, must go in base64 DER format:
const der_certificate = certificate.toString().replace(/[\r\n]/gm, '').slice(27, -25)


// Build the JWS with our payload using the header required by Shinkansen and PS256 algorithm
const jws = (await new jose.FlattenedSign(new Uint8Array(
  Buffer.from(payload, 'utf-8')))).setProtectedHeader(
  {
    alg: ALGORITHM, // PS256 algorithm
    b64: false,     // The b64 = false header
    crit: ['b64'],  // Required for b64 header
    x5c: [der_certificate] // The x509 header
    }
)

// Sign with the private key
const signature = await jws.sign(privateKey)

// Extract only the protected header and signature, to build the compact
// detached representation
const jws_header = signature.protected + '..' + signature.signature
jws_header.length

// Build the request and send it.
const shinkansen_api = axios.create(
  {
    baseURL: 'https://dev.shinkansen.finance/v1'
    headers:{'shinkansen-api-key': 'apikey',
    "content-type": "application/json"
    }
  }
)

try{
  const response = await shinkansen_api.post(
    '/messages/payouts',
    payload,
    {
      headers:
      {
        'shinkansen-jws-signature': jws_header
      }
    }
  )
// ...
}
catch(e){
    // ...
}

using Jose;
using System.Linq;
using System.Text;
using System.Collections;
using Newtonsoft.Json.Linq;
using System.Net.Http.Headers;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Reflection.PortableExecutable;
using System.Security.Cryptography.X509Certificates;

// HttpClient is intended to be instantiated once per application, rather than per-use.
HttpClient client = new();

// The message to sign...You will probably put this on an Object/Class that is Serializable
// to Json or StringContent
string shinkansen_message_body = """
  {"document": {"header" : {
   # ... (the rest of the message body which needs to be signed)
  }}}
""";

// In order to send the Json String, we ned a StringContent payload (JSON-encoded string)
// Since we are signing this string, you MUST send this exact payload
// on the HTTP request if you pass the original shinkansen_message_body
// it may be re-encoded on a slightly different way and that will make the signature invalid.
StringContent payload = new (shinkansen_message_body, Encoding.UTF8, "application/json");

// Load RSA key and certificate from file system, password from env var
// If your key is not password protected you need to use CreateFromPemFile() instead
var certificatePair = X509Certificate2.CreateFromEncryptedPemFile(
  "cert.pem", Environment.GetEnvironmentVariable("PRIVATE_KEY_PASSWORD"), "key.pem");

// x5c header, must go in base64 DER format:
// GetRawCertData return byte representation of the cert (DER)
// Base64FormattingOptions.None return the base64 representation without space
var certificate_der_b64 = Convert.ToBase64String(
  certificatePair.GetRawCertData(), Base64FormattingOptions.None);

// Build the JWS with our payload and sign with the private key using the header
// required by Shinkansen and PS256 algorithm
var jws_signature_header = JWT.Encode(
  payload.ReadAsStringAsync().Result, certificatePair.GetRSAPrivateKey(),
  JwsAlgorithm.PS256, options: new JwtOptions
{
    DetachPayload = true,  // As requested by Shinkansen
    EncodePayload = false, // The b64 = false header
                           // "crit": ["b64"] is provided automatically by Jose.JWT
}, extraHeaders: new Dictionary<string, object>
{
    {"x5c", new List<string>() { certificate_der_b64} } // The x509 header
});

// Build the request
client.BaseAddress = new Uri("https://dev.shinkansen.finance/v1"); // Base URL for DEV env
client.DefaultRequestHeaders.Accept.Add(
  new MediaTypeWithQualityHeaderValue("application/json")); // Set Accept header

var request = new HttpRequestMessage
{
    RequestUri = new("/messages/payouts", UriKind.Relative), // Payouts endpoint
    Method = HttpMethod.Post,
    Headers = {
        {"Shinkansen-Api-Key", Environment.GetEnvironmentVariable("SHINKANSEN_API_KEY")},
        {"Shinkansen-JWS-Signature" , jws_signature_header}, //The JWS Signature
    },
    Content = payload
};

// Call asynchronous network methods in a try/catch block to handle exceptions.
// And we are done!
try
{
    HttpResponseMessage response = client.SendAsync(request).Result;
    //...
}
catch {/*...*/}

Pruebas

Para probar, te recomendamos usar nuestra librería de referencia en Python. Para eso te la puedes bajar via:

pip install python-shinkansen

Las pruebas requieren que cuentes con un certificado y una llave privada. Si no tienes aún tu certificado real o de pruebas, puedes generar archivos para pruebas locales de esta forma (asumiendo que tienes openssl instalado):

openssl req -x509 \
            -newkey rsa:2048 -keyout test-key.pem  \
            -out test-cert.pem -sha256 -days 3650

Te pedirá una password para proteger el archivo de la llave privada. Aunque sea un certificado para jugar, te recomendamos protegerlo con una passephrase para nunca perder la costumbre de manejar estas cosas con mucha precaución.

También te pedirá indicar información para el certificado. No lo dejes en blanco, o fallará la creación del certificado.

Después de este proceso, tendrás dos archivos generados:

  • test-key.pem: Llave privada RSA de pruebas
  • test-cert.pem: Certificado auto-firmado para probar

Ahora podemos usar la librería de referencia (recuerda tenerla instalada via pip install python-shinkansen) para hacer algunas pruebas simples:

echo '{"hola": "mundo"}' > payload.txt
python3 -m shinkansen.jws \
  sign test-cert.pem \
       --key test-key.pem \
       --payload payload.txt \
       --output jws.txt \
       --password TU-PASSWORD-ACA

Eso va a generar un archivo jws.txt.

Ahora verifiquemos la firma:

python3 -m shinkansen.jws \
  verify test-cert.pem \
         --jws jws.txt \
         --payload payload.txt

Si no te aparece ningún error, entonces significa que la firma está correcta. Veamos que pasa si modificamos el contenido:

echo '{"hello": "world"}' > otro-payload.txt
python3 -m shinkansen.jws \
  verify test-cert.pem \
         --jws jws.txt \
         --payload otro-payload.txt

Debieras observar un error que termina con:

jwcrypto.jws.InvalidJWSSignature: Verification failed for all signatures["Failed: [InvalidJWSSignature('Verification failed')]"]

Si pruebas con otras permutaciones (ej: cambiando el certificado usado para generar el JWS y el usado para validarlo, o modificando jws.txt) también obtendrás errores.

📘

Las firmas con PS256 son probabilístcas

Eso significa que si vuelves a firmar el mismo payload.txt con la misma llave y certificado... ¡obtendrás una firma distinta! Por ende no puedes comparar dos firmas para ver si todo está ok. Lo que se debe hacer es verificar la firma.

Ahora que cuentas con estas herramientas puedes :

  • Generar tus propias firmas JWS y chequear si se verifican correctamente con python3 -m shinkansen.jws verify ...
  • Generar una firma con python3 -m shinkansen.jws sign ... y chequear si la puedes verificar correctamente con tu código.

👍

Todo esto sólo es necesario si debes o quieres escribir tu propio código

¿Quizás nuestras librerías no soportan las tecnologías que estás usando? Avísanos y trataremos de escribir una que te sirva.

📘

Si tienes dudas

¡Pídenos ayuda acá o en el slack compartido (si ya eres cliente). Queremos que integrar Shinkansen sea algo fácil y probablemente lo más enredado sea esto de las firmas. Como es importante para mantener todo seguro no pudimos dejar todo sólo con API Keys. Es probable que vayamos generando SDKs en algunos lenguajes mas populares. Así que tu solicitud de ayuda funciona también como un voto para el SDK en tu lenguaje favorito.