# 🍓 Odentas Sign + Raspberry Pi 5 + EIDUCIO+ - Architecture Complète **Système de signature électronique souverain avec scellement qualifié in-house** --- ## 🎯 Vue d'ensemble ### Workflow Complet ``` ┌─────────────────────────────────────────────────────────────────┐ │ 1. SIGNATURE EMPLOYEUR (Odentas Sign) │ │ ↓ Interface web /signer/[requestId]/[employerId] │ │ ↓ OTP par email → Canvas signature → Upload S3 │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 2. SIGNATURE SALARIÉ (Odentas Sign) │ │ ↓ Interface web /signer/[requestId]/[salarierId] │ │ ↓ OTP par email → Canvas signature → Upload S3 │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 3. INJECTION SIGNATURES DANS PDF │ │ ↓ Webhook completion détecte toutes signatures │ │ ↓ pdf-lib injecte les images aux positions définies │ │ ↓ Upload PDF "avec signatures" dans S3 │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 4. SCELLEMENT PAdES (Raspberry Pi 5 + EIDUCIO+) │ │ ↓ Appel API Raspberry Pi: POST /seal-contract │ │ ↓ Signature qualifiée avec clé USB EIDUCIO+ │ │ ↓ Format PAdES avec métadonnées : │ │ - Name: "Renaud [NOM], Président ODENTAS SAS" │ │ - Reason: "Certification tiers de confiance Odentas Sign" │ │ - Location: "Paris, France" │ │ ↓ Upload PDF scellé dans S3 │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 5. HORODATAGE TSA QUALIFIÉ │ │ ↓ Calcul hash SHA-256 du PDF scellé │ │ ↓ Appel TSA (FreeTSA ou Universign) │ │ ↓ Réception token RFC 3161 │ │ ↓ Upload token TSA dans S3 │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 6. ARCHIVAGE CONFORME (S3 Object Lock) │ │ ↓ Bucket: odentas-sign/sealed/ │ │ ↓ Object Lock: COMPLIANCE mode, 10 ans │ │ ↓ Mise à jour sign_assets (hashes, dates, clés S3) │ │ ↓ Evidence bundle complet │ └─────────────────────────────────────────────────────────────────┘ ``` --- ## 🏗️ Architecture Technique ### **1. Frontend : Odentas Sign (Existant)** **Interface de signature** : `/app/signer/[requestId]/[signerId]/` - ✅ **Authentification OTP** par email (15 min) - ✅ **Canvas HTML5** pour dessiner la signature - ✅ **Upload d'image** de signature (JPG/PNG) - ✅ **Consentement explicite** (case à cocher) - ✅ **Responsive** (mobile-friendly) **Composants clés** : - `SignatureCapture.tsx` : Interface de dessin/upload - `OTPVerification.tsx` : Vérification code OTP - `DocumentPreview.tsx` : Aperçu du PDF à signer **API Routes utilisées** : ``` POST /api/odentas-sign/requests/create POST /api/odentas-sign/signers/[id]/send-otp POST /api/odentas-sign/signers/[id]/verify-otp POST /api/odentas-sign/signers/[id]/sign GET /api/odentas-sign/signers/[id]/status ``` --- ### **2. Backend : Next.js API Routes** #### **A. Création de demande** **Route** : `/app/api/odentas-sign/requests/create/route.ts` ```typescript // INPUT { contractId: "CDDU-2025-0102", contractRef: "CDDU-2025-0102", pdfS3Key: "source/contract-xxx.pdf", title: "Contrat CDDU - Jean Dupont", signers: [ { role: "Employeur", name: "Théâtre X", email: "contact@theatre-x.fr" }, { role: "Salarié", name: "Jean Dupont", email: "jean.dupont@example.com" } ], positions: [ { role: "Employeur", page: 3, x: 70, y: 120, w: 180, h: 70 }, { role: "Salarié", page: 3, x: 350, y: 120, w: 180, h: 70 } ] } // ACTIONS 1. INSERT INTO sign_requests (ref, title, source_s3_key, status='pending') 2. INSERT INTO signers (2 lignes : Employeur + Salarié) 3. INSERT INTO sign_positions (2 lignes) 4. INSERT INTO sign_events ('request_created') 5. Envoyer email avec lien de signature à l'employeur // OUTPUT { requestId: "uuid-xxx", signers: [ { id: "uuid-employeur", role: "Employeur", signUrl: "/signer/xxx/uuid-employeur" }, { id: "uuid-salarie", role: "Salarié", signUrl: "/signer/xxx/uuid-salarie" } ] } ``` #### **B. Webhook de Completion** **Route** : `/app/api/odentas-sign/webhooks/completion/route.ts` **Déclenché quand** : Les 2 signataires ont signé ```typescript async function handleCompletion(requestId: string) { // 1. Récupérer la demande et les signataires const { data: request } = await supabase .from('sign_requests') .select('*, signers(*)') .eq('id', requestId) .single(); // 2. Vérifier que tous ont signé const allSigned = request.signers.every(s => s.signed_at !== null); if (!allSigned) return; // 3. Télécharger le PDF source depuis S3 const sourcePdf = await downloadFromS3(request.source_s3_key); // 4. Injecter les signatures graphiques avec pdf-lib const pdfWithSignatures = await injectSignatureImages(sourcePdf, request.signers); // 5. Upload PDF avec signatures (non scellé encore) const intermediateKey = `intermediate/${request.ref}.pdf`; await uploadToS3(intermediateKey, pdfWithSignatures); // 6. NOUVEAU : Envoyer au Raspberry Pi pour scellement PAdES const sealedPdf = await sealWithRaspberryPi(intermediateKey, request); // 7. NOUVEAU : Obtenir horodatage TSA const tsaToken = await getTSATimestamp(sealedPdf); // 8. Upload final avec Object Lock const finalKey = `sealed/${request.ref}.pdf`; await uploadToS3WithObjectLock(finalKey, sealedPdf, { retention: '10 years' }); // 9. Mettre à jour sign_assets await supabase.from('sign_assets').upsert({ request_id: requestId, signed_pdf_s3_key: finalKey, pdf_sha256: sha256(sealedPdf), tsa_token_sha256: sha256(tsaToken), sealed_at: new Date(), seal_algo: 'RSASSA_PKCS1_SHA_256', seal_certificate_issuer: 'ChamberSign EIDUCIO+', tsa_provider: 'FreeTSA', retain_until: new Date(Date.now() + 10 * 365 * 24 * 60 * 60 * 1000) }); // 10. Mettre à jour le statut await supabase .from('sign_requests') .update({ status: 'completed' }) .eq('id', requestId); } async function sealWithRaspberryPi(pdfS3Key: string, request: SignRequest) { const authToken = crypto .createHmac('sha256', process.env.RASPBERRY_PI_SECRET!) .update('seal-server') .digest('hex'); // Télécharger le PDF const pdfBuffer = await downloadFromS3(pdfS3Key); // Envoyer au Raspberry Pi const response = await fetch(`${process.env.RASPBERRY_PI_URL}/seal-contract`, { method: 'POST', headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/pdf', 'X-Request-Id': request.id, 'X-Contract-Ref': request.ref, }, body: pdfBuffer, }); if (!response.ok) { throw new Error(`Raspberry Pi seal failed: ${await response.text()}`); } return Buffer.from(await response.arrayBuffer()); } ``` --- ### **3. Serveur Raspberry Pi 5** #### **Configuration Matérielle** ```yaml Hardware: Model: Raspberry Pi 5 (8GB RAM) Storage: SD 64GB + SSD USB 128GB (logs) Network: Ethernet Gigabit (fibre 8Gb/s) USB: Clé ChamberSign EIDUCIO+ branchée 24/7 Power: Alimentation officielle 27W USB-C Case: Boîtier avec ventilateur actif Software: OS: Raspberry Pi OS Lite (64-bit) Runtime: Node.js 20 LTS (via nvm) Process Manager: PM2 (auto-restart) VPN: Tailscale (accès sécurisé depuis Vercel) Firewall: UFW (port 3001 uniquement depuis LAN) Coût: One-time: ~230€ (Pi 5 + accessoires) Annuel: 108€ TTC (EIDUCIO+) + ~10€ (électricité) ``` #### **API Node.js Express** **Fichier** : `/home/pi/signature-server/src/index.ts` ```typescript import express from 'express'; import bodyParser from 'body-parser'; import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; import crypto from 'crypto'; import { PKCS11 } from 'pkcs11js'; import { signPdfPAdES } from './pades-signer'; import { createLogger } from 'winston'; // Configuration const PORT = 3001; const PIN = process.env.USB_PIN; // PIN EIDUCIO+ const PKCS11_MODULE = '/usr/lib/aarch64-linux-gnu/opensc-pkcs11.so'; const API_SECRET = process.env.API_SECRET; // Logger const logger = createLogger({ transports: [ new transports.File({ filename: '/var/log/signature-server/seal.log' }), new transports.Console() ] }); const app = express(); // Sécurité app.use(helmet()); app.use(bodyParser.raw({ type: 'application/pdf', limit: '10mb' })); app.use(rateLimit({ windowMs: 60 * 60 * 1000, max: 100 })); // Authentification const authenticate = (req, res, next) => { const token = req.headers['authorization']?.substring(7); const expectedToken = crypto .createHmac('sha256', API_SECRET) .update('seal-server') .digest('hex'); if (token !== expectedToken) { logger.warn('Token invalide', { ip: req.ip }); return res.status(403).json({ error: 'Unauthorized' }); } next(); }; // Route de scellement pour contrats app.post('/seal-contract', authenticate, async (req, res) => { const requestId = req.headers['x-request-id']; const contractRef = req.headers['x-contract-ref']; logger.info('Début scellement', { requestId, contractRef }); try { // Metadata PAdES const metadata = { name: "Renaud [NOM]", organization: "ODENTAS SAS", role: "Président", reason: `Certification et archivage sécurisé du contrat ${contractRef} - Odentas Sign`, location: "Paris, France", contactInfo: "odentas-sign@odentas.fr", customProperties: { service: "Odentas Sign", tiersDConfiance: true, contractRef, requestId, } }; // Signer avec EIDUCIO+ const sealedPdf = await signPdfPAdES(req.body, metadata, { pkcs11Module: PKCS11_MODULE, pin: PIN, }); logger.info('Scellement réussi', { requestId, size: sealedPdf.length, duration: Date.now() - startTime }); res.setHeader('Content-Type', 'application/pdf'); res.send(sealedPdf); } catch (error) { logger.error('Erreur scellement', { error: error.message, requestId }); res.status(500).json({ error: 'Seal failed', details: error.message }); } }); // Route pour factures app.post('/seal-invoice', authenticate, async (req, res) => { const invoiceNumber = req.headers['x-invoice-number']; logger.info('Début scellement facture', { invoiceNumber }); try { const metadata = { name: "Renaud [NOM]", organization: "ODENTAS SAS", role: "Président", reason: `Facture électronique conforme - Article 289 CGI`, location: "Paris, France", contactInfo: "facturation@odentas.fr", customProperties: { invoiceNumber, type: "invoice", } }; const sealedPdf = await signPdfPAdES(req.body, metadata, { pkcs11Module: PKCS11_MODULE, pin: PIN, }); logger.info('Facture scellée', { invoiceNumber }); res.setHeader('Content-Type', 'application/pdf'); res.send(sealedPdf); } catch (error) { logger.error('Erreur facture', { error: error.message, invoiceNumber }); res.status(500).json({ error: 'Seal failed' }); } }); // Health check app.get('/health', (req, res) => { res.json({ status: 'ok', uptime: process.uptime(), usb_key_detected: checkUSBKey(), timestamp: new Date().toISOString() }); }); function checkUSBKey() { try { const pkcs11 = new PKCS11(); pkcs11.load(PKCS11_MODULE); pkcs11.C_Initialize(); const slots = pkcs11.C_GetSlotList(true); pkcs11.C_Finalize(); return slots.length > 0; } catch { return false; } } app.listen(PORT, '0.0.0.0', () => { logger.info(`Serveur de scellement démarré`, { port: PORT }); }); ``` #### **Module de Signature PAdES** **Fichier** : `/home/pi/signature-server/src/pades-signer.ts` ```typescript import { PKCS11 } from 'pkcs11js'; import forge from 'node-forge'; import { PDFDocument } from 'pdf-lib'; export async function signPdfPAdES( pdfBuffer: Buffer, metadata: SignatureMetadata, options: { pkcs11Module: string; pin: string } ): Promise { const pkcs11 = new PKCS11(); try { // 1. Initialiser PKCS#11 pkcs11.load(options.pkcs11Module); pkcs11.C_Initialize(); // 2. Trouver le slot de la clé USB const slots = pkcs11.C_GetSlotList(true); if (slots.length === 0) throw new Error('Clé USB non détectée'); const slotId = slots[0]; // 3. Ouvrir session et se connecter const session = pkcs11.C_OpenSession( slotId, pkcs11.CKF_SERIAL_SESSION | pkcs11.CKF_RW_SESSION ); pkcs11.C_Login(session, pkcs11.CKU_USER, options.pin); // 4. Trouver la clé privée pkcs11.C_FindObjectsInit(session, [ { type: pkcs11.CKA_CLASS, value: pkcs11.CKO_PRIVATE_KEY }, { type: pkcs11.CKA_SIGN, value: true } ]); const privateKeys = pkcs11.C_FindObjects(session, 1); pkcs11.C_FindObjectsFinal(session); if (privateKeys.length === 0) throw new Error('Clé privée non trouvée'); const privateKey = privateKeys[0]; // 5. Récupérer le certificat public pkcs11.C_FindObjectsInit(session, [ { type: pkcs11.CKA_CLASS, value: pkcs11.CKO_CERTIFICATE } ]); const certs = pkcs11.C_FindObjects(session, 1); pkcs11.C_FindObjectsFinal(session); const certDer = pkcs11.C_GetAttributeValue(session, certs[0], [ { type: pkcs11.CKA_VALUE } ])[0].value as Buffer; const cert = forge.pki.certificateFromAsn1( forge.asn1.fromDer(forge.util.createBuffer(certDer)) ); // 6. Préparer le PDF pour signature const pdfDoc = await PDFDocument.load(pdfBuffer); // 7. Créer le dictionnaire de signature PAdES const signingTime = new Date(); const signatureDict = { Type: 'Sig', Filter: 'Adobe.PPKLite', SubFilter: 'ETSI.CAdES.detached', // Format PAdES Name: `${metadata.name}, ${metadata.role} - ${metadata.organization}`, Reason: metadata.reason, Location: metadata.location, ContactInfo: metadata.contactInfo, M: `D:${formatPdfDate(signingTime)}`, // ByteRange et Contents seront calculés }; // 8. Calculer le hash du PDF const pdfHash = crypto.createHash('sha256').update(pdfBuffer).digest(); // 9. Signer avec PKCS#11 const mechanism = { mechanism: pkcs11.CKM_SHA256_RSA_PKCS }; pkcs11.C_SignInit(session, mechanism, privateKey); const signature = pkcs11.C_Sign(session, pdfHash, Buffer.alloc(256)); // 10. Créer structure CAdES (PAdES requiert CAdES) const signedData = createCAdESStructure(pdfHash, signature, cert, signingTime); // 11. Intégrer la signature dans le PDF const signedPdf = await embedSignatureInPdf(pdfBuffer, signatureDict, signedData); // 12. Nettoyer pkcs11.C_Logout(session); pkcs11.C_CloseSession(session); pkcs11.C_Finalize(); return signedPdf; } catch (error) { // Toujours nettoyer en cas d'erreur try { pkcs11.C_Finalize(); } catch {} throw error; } } function createCAdESStructure( messageDigest: Buffer, signature: Buffer, certificate: forge.pki.Certificate, signingTime: Date ): Buffer { // Créer structure CMS SignedData (simplifié) const signedAttrs = [ { oid: '1.2.840.113549.1.9.3', value: 'application/pdf' }, // contentType { oid: '1.2.840.113549.1.9.5', value: signingTime }, // signingTime { oid: '1.2.840.113549.1.9.4', value: messageDigest }, // messageDigest ]; // Utiliser node-forge pour créer structure ASN.1 const p7 = forge.pkcs7.createSignedData(); p7.addCertificate(certificate); p7.addSigner({ key: { sign: () => signature }, // Signature déjà calculée certificate: certificate, digestAlgorithm: forge.pki.oids.sha256, authenticatedAttributes: signedAttrs }); return Buffer.from(forge.asn1.toDer(p7.toAsn1()).getBytes(), 'binary'); } function embedSignatureInPdf( pdfBuffer: Buffer, signatureDict: any, signedData: Buffer ): Buffer { // Utiliser pdf-lib ou manipulation directe du PDF // Insérer le dictionnaire de signature dans le PDF // ByteRange = [0, offsetBeforeSignature, offsetAfterSignature, lengthAfterSignature] // Contents = signedData en hexadécimal // Implémentation simplifiée (voir librairies comme node-signpdf) // ... return modifiedPdfBuffer; } function formatPdfDate(date: Date): string { // Format: YYYYMMDDHHmmssZ return date.toISOString().replace(/[-:TZ]/g, '').slice(0, 14) + 'Z'; } interface SignatureMetadata { name: string; organization: string; role: string; reason: string; location: string; contactInfo: string; customProperties?: Record; } ``` --- ### **4. Service d'Horodatage TSA** #### **Option A : FreeTSA (Gratuit)** ```typescript // /lib/tsa/freetsa.ts import fetch from 'node-fetch'; import crypto from 'crypto'; import forge from 'node-forge'; export async function getFreeTSATimestamp(pdfHash: Buffer): Promise { // 1. Créer requête TSA (RFC 3161) const tsq = forge.pki.createTimeStampRequest({ messageImprint: { hashAlgorithm: forge.pki.oids.sha256, digest: pdfHash.toString('binary') }, certReq: true }); const tsqDer = Buffer.from(forge.asn1.toDer(tsq.toAsn1()).getBytes(), 'binary'); // 2. Envoyer à FreeTSA const response = await fetch('https://freetsa.org/tsr', { method: 'POST', headers: { 'Content-Type': 'application/timestamp-query' }, body: tsqDer, }); if (!response.ok) { throw new Error(`TSA request failed: ${response.statusText}`); } // 3. Récupérer réponse (TSR) const tsrDer = Buffer.from(await response.arrayBuffer()); // 4. Valider la réponse const tsr = forge.pki.createTimeStampResponse( forge.asn1.fromDer(forge.util.createBuffer(tsrDer)) ); if (tsr.status !== 0) { throw new Error(`TSA response error: ${tsr.statusString}`); } return tsrDer; } ``` #### **Option B : Universign TSA (50-100€/an)** ```typescript // /lib/tsa/universign.ts export async function getUniversignTimestamp(pdfHash: Buffer): Promise { const response = await fetch('https://timestamp.universign.eu/rfc3161', { method: 'POST', headers: { 'Content-Type': 'application/timestamp-query', 'Authorization': `Bearer ${process.env.UNIVERSIGN_API_KEY}` }, body: createTSQRequest(pdfHash), }); return Buffer.from(await response.arrayBuffer()); } ``` --- ### **5. Tables Supabase - Ajouts** #### **Migration : Colonnes Raspberry Pi** ```sql -- Ajouter colonnes pour certificat EIDUCIO+ ALTER TABLE sign_assets ADD COLUMN seal_certificate_issuer TEXT DEFAULT 'ChamberSign EIDUCIO+', ADD COLUMN seal_certificate_subject TEXT DEFAULT 'Renaud [NOM], Président ODENTAS SAS', ADD COLUMN seal_certificate_serial TEXT, ADD COLUMN tsa_provider TEXT DEFAULT 'FreeTSA', ADD COLUMN tsa_url TEXT; -- Index pour recherche CREATE INDEX idx_sign_assets_sealed_at ON sign_assets(sealed_at); CREATE INDEX idx_sign_assets_retain_until ON sign_assets(retain_until); ``` --- ### **6. Configuration S3 Object Lock** ```bash # Créer bucket dédié avec Object Lock aws s3api create-bucket \ --bucket odentas-sign-sealed \ --region eu-west-3 \ --object-lock-enabled-for-bucket # Configurer Object Lock par défaut (10 ans) aws s3api put-object-lock-configuration \ --bucket odentas-sign-sealed \ --object-lock-configuration '{ "ObjectLockEnabled": "Enabled", "Rule": { "DefaultRetention": { "Mode": "COMPLIANCE", "Years": 10 } } }' # Activer versioning (requis pour Object Lock) aws s3api put-bucket-versioning \ --bucket odentas-sign-sealed \ --versioning-configuration Status=Enabled ``` --- ## 🔒 Sécurité ### **1. Réseau Raspberry Pi** ```bash # Tailscale VPN (recommandé) curl -fsSL https://tailscale.com/install.sh | sh sudo tailscale up # Firewall UFW sudo ufw allow from 192.168.1.0/24 to any port 3001 sudo ufw enable # Fail2ban (protection brute-force) sudo apt install fail2ban ``` ### **2. Secrets Management** ```bash # .env sur Raspberry Pi PORT=3001 USB_PIN=YOUR_EIDUCIO_PIN API_SECRET=your-super-secret-key-256-bits LOG_LEVEL=info # Variables Vercel RASPBERRY_PI_URL=http://100.64.0.50:3001 # Tailscale IP RASPBERRY_PI_SECRET=same-as-above ``` ### **3. Monitoring & Alertes** ```bash # PM2 monitoring pm2 install pm2-logrotate pm2 set pm2-logrotate:max_size 100M pm2 set pm2-logrotate:retain 30 # Alertes email en cas d'erreur npm install nodemailer # Configurer dans le serveur pour envoyer email si clé USB déconnectée ``` --- ## 📊 Performance ### **Temps de Traitement** | Étape | Durée | |-------|-------| | Signature Employeur (OTP + Canvas) | ~1-2 min (utilisateur) | | Signature Salarié (OTP + Canvas) | ~1-2 min (utilisateur) | | Injection signatures dans PDF | ~500ms | | Scellement PAdES (Raspberry Pi) | ~800ms | | Horodatage TSA | ~200ms | | Upload S3 Object Lock | ~300ms | | **Total automatique** | **~2 secondes** | --- ## 💰 Coûts Récapitulatifs | Élément | Coût | |---------|------| | **Raspberry Pi 5 + accessoires** | 230€ one-time | | **Clé EIDUCIO+ ChamberSign** | 108€ TTC/an | | **TSA FreeTSA** | Gratuit | | **TSA Universign (optionnel)** | 50-100€/an | | **S3 Storage (10 000 contrats/an)** | ~5€/an | | **Électricité Raspberry Pi** | ~10€/an | | **Total première année** | ~353€ | | **Années suivantes** | ~123€/an | **Comparaison** : Service cloud (Universign/DocuSign) = 1500-3000€/an --- ## 🚀 Mise en Production ### **Checklist** - [ ] Commander clé EIDUCIO+ (90€ HT/an) - [ ] Acheter Raspberry Pi 5 + accessoires - [ ] Installer Raspberry Pi OS + Node.js + PM2 - [ ] Brancher clé USB EIDUCIO+ et tester détection - [ ] Déployer serveur signature-server - [ ] Configurer Tailscale VPN - [ ] Créer bucket S3 avec Object Lock - [ ] Migrer tables Supabase (colonnes seal_*) - [ ] Tester workflow complet en staging - [ ] Déployer en production - [ ] Former équipe staff sur nouvelle interface - [ ] Désactiver DocuSeal progressivement --- ## 📚 Documentation Complémentaire - **ODENTAS_SIGN_API.md** : API complète Odentas Sign - **SIGNATURE_MULTI_PARTIES.md** : Signatures multiples avec metadata - **TEST_PADES_TSA.md** : Tests du workflow complet - **ODENTAS_SIGN_INTERFACE.md** : Interface utilisateur --- **Questions ou besoin d'aide pour l'implémentation ?** 🍓