espace-paie-odentas/lambda-tsaStamp/index.js
odentas b790faf12c feat: Implémentation complète du système Odentas Sign
- Remplacement de DocuSeal par solution souveraine Odentas Sign
- Système d'authentification OTP pour signataires (bcryptjs + JWT)
- 8 routes API: send-otp, verify-otp, sign, pdf-url, positions, status, webhook, signers
- Interface moderne avec canvas de signature et animations (framer-motion, confetti)
- Système de templates pour auto-détection des positions de signature (CDDU, RG, avenants)
- PDF viewer avec @react-pdf-viewer (compatible Next.js)
- Stockage S3: source/, signatures/, evidence/, signed/, certs/
- Tables Supabase: sign_requests, signers, sign_positions, sign_events, sign_assets
- Evidence bundle automatique (JSON metadata + timestamps)
- Templates emails: OTP et completion
- Scripts Lambda prêts: pades-sign (KMS seal) et tsaStamp (RFC3161)
- Mode test détecté automatiquement (emails whitelist)
- Tests complets avec PDF CDDU réel (2 signataires)
2025-10-27 19:03:07 +01:00

110 lines
4.1 KiB
JavaScript

import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import crypto from "node:crypto";
import { spawn } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import { createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
const s3 = new S3Client({ region: process.env.REGION || process.env.AWS_REGION || "eu-west-3" });
const BUCKET = process.env.BUCKET;
const TSA_URL = process.env.TSA_URL || "https://timestamp.sectigo.com";
const DEFAULT_TSR_PREFIX = process.env.DEFAULT_TSR_PREFIX || "evidence/tsa/";
const DEFAULT_REQ_PREFIX = process.env.DEFAULT_REQ_PREFIX || "evidence/tsq/";
/**
* Attends un event JSON de la forme :
* {
* "requestRef": "CDDU-2025-0102",
* "pdfSha256": "<hex>", // optionnel si pdfS3Key présent
* "pdfS3Key": "source/contrat.pdf", // optionnel si pdfSha256 présent
* "tsrS3Key": "evidence/tsa/CDDU-2025-0102.tsr" // optionnel (sinon généré)
* }
*/
export const handler = async (event = {}) => {
try {
const requestRef = event.requestRef || randomRef();
let pdfSha256 = (event.pdfSha256 || "").trim();
if (!pdfSha256 && event.pdfS3Key) {
pdfSha256 = await sha256OfS3Object(BUCKET, event.pdfS3Key);
}
if (!pdfSha256 || !/^[0-9a-fA-F]{64}$/.test(pdfSha256)) {
throw new Error("pdfSha256 manquant ou invalide (attendu : hex 64 chars)");
}
// 1) Générer la requête RFC3161 (.tsq) via openssl
const tsqPath = `/tmp/${requestRef}.tsq`;
await genTsqWithOpenssl(pdfSha256, tsqPath);
// 2) Appeler la TSA
const tsrPath = `/tmp/${requestRef}.tsr`;
await postTsqToTsa(tsqPath, TSA_URL, tsrPath);
// 3) Hasher la réponse TSA
const tsrBuf = await readFile(tsrPath);
const tsrSha256 = crypto.createHash("sha256").update(tsrBuf).digest("hex");
// 4) Uploader .tsq et .tsr dans S3
const tsqKey = event.tsqS3Key || `${DEFAULT_REQ_PREFIX}${requestRef}.tsq`;
const tsrKey = event.tsrS3Key || `${DEFAULT_TSR_PREFIX}${requestRef}.tsr`;
await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: tsqKey, Body: await readFile(tsqPath), ContentType: "application/timestamp-query" }));
await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: tsrKey, Body: tsrBuf, ContentType: "application/timestamp-reply" }));
// 5) Réponse
return ok({
requestRef,
tsa_url: TSA_URL,
pdf_sha256: pdfSha256.toLowerCase(),
tsq_s3_key: tsqKey,
tsr_s3_key: tsrKey,
tsr_sha256: tsrSha256,
message: "RFC3161 timestamp acquired"
});
} catch (err) {
console.error(err);
return errResp(err);
}
};
// Utilitaires
function randomRef() {
return `TS-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
}
async function sha256OfS3Object(bucket, key) {
const res = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
const hash = crypto.createHash("sha256");
await pipeline(res.Body, hash);
return hash.digest("hex");
}
async function genTsqWithOpenssl(hexDigest, outPath) {
// openssl ts -query -sha256 -digest <hex> -cert -no_nonce -out /tmp/req.tsq
await exec("openssl", ["ts", "-query", "-sha256", "-digest", hexDigest, "-cert", "-no_nonce", "-out", outPath]);
}
async function postTsqToTsa(tsqPath, url, outPath) {
// curl -sS -H "Content-Type: application/timestamp-query" --data-binary @file url > out
await exec("curl", ["-sS", "-H", "Content-Type: application/timestamp-query", "--data-binary", `@${tsqPath}`, url, "-o", outPath]);
}
async function exec(cmd, args) {
return new Promise((resolve, reject) => {
const p = spawn(cmd, args);
let stderr = "";
p.stderr.on("data", (d) => (stderr += d.toString()));
p.on("exit", (code) => {
if (code === 0) resolve(0);
else reject(new Error(`${cmd} exited with ${code}: ${stderr}`));
});
});
}
function ok(payload) {
return { statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify(payload) };
}
function errResp(err) {
return { statusCode: 500, headers: { "content-type": "application/json" }, body: JSON.stringify({ error: String(err) }) };
}