services/api/src/config/altcha.js

Overview

Este módulo centraliza la emisión, persistencia y verificación de challenges ALTCHA para la API. Usa altcha-lib con PBKDF2/SHA-256, deriva una firma HMAC auxiliar, y guarda cada challenge emitido para impedir replays. El mismo contrato se reutiliza en flujos del backend que necesitan una prueba de resolución antes de aceptar una operación sensible.

Cuando Redis está disponible, el módulo usa un replay store distribuido con clave prefijada y TTL. Si no hay URL de Redis, cae a un almacenamiento en memoria con Map, que también elimina entradas expiradas antes de registrar nuevos challenges.

[!NOTE] verifyAndConsumeAltchaPayload consume el challenge antes de validar el propósito o la identidad asociada. Eso significa que un intento inválido también agota el challenge emitido.

How it works

El flujo se divide en dos rutas principales: emisión y verificación. La primera crea un challenge nuevo, le asigna un challengeId único, lo guarda en Redis o memoria, y devuelve el objeto para que el cliente lo resuelva. La segunda decodifica el payload, valida su forma, consume el challengeId una sola vez y luego verifica la solución con la misma clave derivada.

flowchart TD
    subgraph Issuance[Challenge issuance]
        A[generateAltchaChallenge] --> B[keySignatureSecret]
        A --> C[createChallenge]
        A --> D[storeChallenge]
        D --> E[getRedisClient]
        D --> F[memoryChallenges]
        E --> G[Redis replay store]
    end

    subgraph Verification[Challenge verification]
        H[verifyAndConsumeAltchaPayload] --> I[parsePayload]
        I --> J[getChallengeMetadata]
        J --> K[consumeChallenge]
        K --> E
        J --> L[verifySolution]
        L --> M[Return true]
    end

generateAltchaChallenge arma el desafío con purpose, userId opcional y el algoritmo fijo del módulo. verifyAndConsumeAltchaPayload lee el payload base64, comprueba el counter, la expiración y el algoritmo, y luego valida la solución. La reutilización del mismo challengeId queda bloqueada por la capa de almacenamiento.

Key components

Este archivo expone tres funciones públicas y varios helpers internos. Las funciones públicas encapsulan todo el ciclo ALTCHA, mientras que los helpers privados se ocupan de la firma, la persistencia temporal y el parsing seguro del payload.

{
    "title": "generateAltchaChallenge",
    "content": "Genera un challenge ALTCHA nuevo, le asigna metadatos de prop\u00f3sito y usuario, lo persiste para evitar replay y devuelve el objeto challenge listo para enviar al cliente.",
    "type": "function",
    "filePath": "services/api/src/config/altcha.js",
    "badges": [
        "public"
    ]
}
{
    "title": "verifyAndConsumeAltchaPayload",
    "content": "Decodifica un payload ALTCHA, valida su estructura, consume el challenge emitido y verifica la soluci\u00f3n. Devuelve true cuando la comprobaci\u00f3n pasa.",
    "type": "function",
    "filePath": "services/api/src/config/altcha.js",
    "badges": [
        "public"
    ]
}
{
    "title": "resetAltchaMemoryStoreForTests",
    "content": "Vac\u00eda el almacenamiento en memoria usado como replay store cuando Redis no est\u00e1 configurado. Est\u00e1 pensado para pruebas.",
    "type": "function",
    "filePath": "services/api/src/config/altcha.js",
    "badges": [
        "public"
    ]
}

Los helpers internos completan la mecánica del módulo:

  • keySignatureSecret() genera una firma HMAC derivada a partir de env.altchaHmacSecret.
  • pruneMemoryChallenges() limpia entradas expiradas del Map local.
  • getRedisClient() crea y cachea el cliente Redis de forma perezosa.
  • storeChallenge() guarda challengeId con TTL y protección NX.
  • consumeChallenge() elimina el challenge una sola vez y detecta expiración o replay.
  • parsePayload() decodifica y valida el JSON base64.
  • getChallengeMetadata() extrae y valida metadatos del challenge antes de verificar la solución.

Data & fields

La forma de los datos aquí es muy estricta. El módulo acepta un payload codificado en base64 con dos objetos principales: challenge y solution. También usa constantes fijas para algoritmo, costo, límites del contador y longitud máxima del payload.

[
    {
        "name": "ALTCHA_ALGORITHM",
        "type": "string",
        "required": true,
        "description": "Algoritmo fijo usado por createChallenge y verifySolution. Valor: PBKDF2/SHA-256."
    },
    {
        "name": "ALTCHA_COST",
        "type": "number",
        "required": true,
        "description": "Costo fijo del challenge. Valor: 5000."
    },
    {
        "name": "ALTCHA_COUNTER_MIN",
        "type": "number",
        "required": true,
        "description": "Valor mínimo aceptado para payload.solution.counter. Valor: 5000."
    },
    {
        "name": "ALTCHA_COUNTER_MAX",
        "type": "number",
        "required": true,
        "description": "Valor máximo aceptado para payload.solution.counter. Valor: 10000."
    },
    {
        "name": "PAYLOAD_MAX_LENGTH",
        "type": "number",
        "required": true,
        "description": "Longitud máxima del payload codificado antes de intentar el parseo. Valor: 64 KiB."
    },
    {
        "name": "STORE_KEY_PREFIX",
        "type": "string",
        "required": true,
        "description": "Prefijo de clave usado en Redis para replay protection. Valor: plano:altcha:challenge:."
    },
    {
        "name": "memoryChallenges",
        "type": "Map<string, number>",
        "required": true,
        "description": "Replay store local usado cuando no existe Redis."
    },
    {
        "name": "redisClientPromise",
        "type": "Promise<Redis client> | undefined",
        "description": "Cache de conexión para el cliente Redis creado de forma perezosa."
    },
    {
        "name": "generateAltchaChallenge options",
        "type": "object",
        "description": "Opciones para emitir un challenge nuevo.",
        "children": [
            {
                "name": "now",
                "type": "number",
                "description": "Marca temporal base para calcular expiresAt."
            },
            {
                "name": "expiresAt",
                "type": "number",
                "description": "Instante absoluto de expiración en milisegundos."
            },
            {
                "name": "purpose",
                "type": "string",
                "description": "Propósito lógico del challenge. Valor por defecto: registration."
            },
            {
                "name": "userId",
                "type": "string",
                "description": "Identificador del usuario que queda asociado al challenge."
            }
        ]
    },
    {
        "name": "verifyAndConsumeAltchaPayload options",
        "type": "object",
        "description": "Opciones de validación del payload consumido.",
        "children": [
            {
                "name": "purpose",
                "type": "string",
                "description": "Propósito esperado para el challenge. Valor por defecto: registration."
            },
            {
                "name": "userId",
                "type": "string",
                "description": "Usuario esperado para comparar contra payload.challenge.parameters.data.userId."
            }
        ]
    },
    {
        "name": "encodedPayload",
        "type": "string",
        "required": true,
        "description": "Payload ALTCHA codificado en base64. Debe contener un JSON con challenge y solution."
    },
    {
        "name": "payload",
        "type": "object",
        "required": true,
        "description": "Objeto JSON decodificado desde encodedPayload.",
        "children": [
            {
                "name": "challenge",
                "type": "object",
                "required": true,
                "description": "Challenge emitido por altcha-lib.",
                "children": [
                    {
                        "name": "parameters",
                        "type": "object",
                        "required": true,
                        "description": "Parámetros del challenge.",
                        "children": [
                            {
                                "name": "algorithm",
                                "type": "string",
                                "required": true,
                                "description": "Debe coincidir con ALTCHA_ALGORITHM."
                            },
                            {
                                "name": "cost",
                                "type": "number",
                                "required": true,
                                "description": "Debe coincidir con ALTCHA_COST."
                            },
                            {
                                "name": "expiresAt",
                                "type": "number",
                                "required": true,
                                "description": "Expiración del challenge expresada en segundos y convertida a milisegundos durante la validación."
                            },
                            {
                                "name": "data",
                                "type": "object",
                                "required": true,
                                "description": "Metadatos incrustados por el emisor.",
                                "children": [
                                    {
                                        "name": "challengeId",
                                        "type": "string",
                                        "required": true,
                                        "description": "Identificador único que se persiste para replay protection."
                                    },
                                    {
                                        "name": "purpose",
                                        "type": "string",
                                        "required": true,
                                        "description": "Propósito lógico del challenge."
                                    },
                                    {
                                        "name": "userId",
                                        "type": "string",
                                        "description": "Identificador del usuario, cuando el emisor lo incluye."
                                    }
                                ]
                            }
                        ]
                    }
                ]
            },
            {
                "name": "solution",
                "type": "object",
                "required": true,
                "description": "Solución enviada por el cliente.",
                "children": [
                    {
                        "name": "counter",
                        "type": "integer",
                        "required": true,
                        "description": "Debe estar entre ALTCHA_COUNTER_MIN y ALTCHA_COUNTER_MAX."
                    }
                ]
            }
        ]
    }
]

Behaviors

Cada función del módulo tiene una responsabilidad bien delimitada. Las funciones internas no se exponen al resto de la aplicación, pero sostienen el flujo de emisión y verificación.

  • keySignatureSecret() — Deriva y devuelve el secreto HMAC usado como hmacKeySignatureSecret.
  • pruneMemoryChallenges(now) — Elimina del Map local los challenges ya expirados.
  • getRedisClient() — Devuelve un cliente Redis conectado o null si no hay URL configurada.
  • storeChallenge(challengeId, expiresAt) — Guarda el challenge con TTL y falla con 503 si Redis no acepta la escritura.
  • consumeChallenge(challengeId, expiresAt) — Consume el challenge exactamente una vez y lanza error si ya fue usado o expiró.
  • parsePayload(encodedPayload) — Decodifica base64, parsea JSON y valida que existan challenge y solution.
  • getChallengeMetadata(payload) — Valida algoritmo, costo, expiración y rango del counter, y devuelve metadatos relevantes.
  • generateAltchaChallenge(options = {}) — Crea un challenge nuevo, lo almacena y devuelve el objeto generado.
  • verifyAndConsumeAltchaPayload(encodedPayload, options = {}) — Verifica un payload y devuelve true si la solución es válida.
  • resetAltchaMemoryStoreForTests() — Limpia el Map interno para dejar el módulo listo para otra prueba.

Workflows

El flujo de ejecución principal es lineal y determinista. La emisión siempre precede al consumo, y el consumo borra el estado de replay antes de aceptar la operación asociada.

sequenceDiagram
    participant C as Caller
    participant A as altcha.js
    participant R as Redis

    C->>A: generateAltchaChallenge
    A->>A: keySignatureSecret
    A->>A: createChallenge
    A->>R: set challengeId with TTL
    R-->>A: OK
    A-->>C: challenge

    C->>A: verifyAndConsumeAltchaPayload
    A->>A: parsePayload
    A->>A: getChallengeMetadata
    A->>R: getDel challengeId
    R-->>A: stored expiresAt or empty
    A->>A: verifySolution
    A-->>C: true

Cuando Redis no está disponible, el flujo usa memoryChallenges como respaldo. Esa ruta mantiene la misma semántica de consumo único y también limpia entradas caducadas antes de guardar un nuevo challenge.

Gotchas & edge cases

[!WARNING] verifyAndConsumeAltchaPayload consume el challenge antes de comprobar el purpose o el userId. Un payload válido en forma, pero destinado a otra operación, queda gastado igual.

  • Un payload que no sea string, esté vacío o supere 64 KiB se rechaza con 400.
  • Un JSON base64 malformado también se rechaza con 400.
  • Si challenge.parameters.algorithm no es PBKDF2/SHA-256, la validación falla.
  • Si solution.counter queda fuera de 5000 a 10000, la validación falla.
  • Si Redis está configurado y set no devuelve OK, storeChallenge lanza 503.
  • Si el challenge ya fue usado o no existe en el replay store, consumeChallenge lanza 400.
  • Si el challenge expiró, la verificación devuelve 400 con error de expiración.

Related

Este archivo depende directamente de services/api/src/config/env.js, que aporta altchaHmacSecret, altchaChallengeTtlMs y altchaRedisUrl. Esa misma configuración decide si el replay store usa Redis o el Map local.

También lo consumen flujos de negocio del backend. services/api/src/modules/contact-gate/contact-gate.service.js usa verifyAndConsumeAltchaPayload para el gate de contacto, con purpose específico de contact-intent. Los tests de services/api/test/altcha-registration.test.js y services/api/test/contact-gate.test.js usan generateAltchaChallenge y resetAltchaMemoryStoreForTests para validar emisión, resolución y replay protection.

ApiError es la vía uniforme para reportar fallos de validación, expiración, replay y problemas de persistencia. El módulo lo usa para devolver errores HTTP coherentes a quienes consumen estas funciones.