espace-paie-odentas/ODENTAS_SIGN_RASPBERRY_PI_ARCHITECTURE.md
odentas e9cb6e7e0e feat: Système unifié d'invitation avec emails d'activation
- Créé sendInvitationWithActivationEmail() pour unifier les invitations
- Modifié /api/staff/users/invite pour utiliser generateLink + email
- Modifié /api/access/nouveau pour envoyer email d'activation
- Modifié /api/access POST pour remplacer pending_invites par système direct
- Template account-activation mis à jour :
  * Titre 'Activez votre compte'
  * Encart avec infos : invitant (statut), organisation, niveau d'accès
  * Message de contact formaté comme autres emails
  * Renommage 'Odentas Paie' → 'Espace Paie Odentas'
- Fix page /activate : délai 100ms pour hash fragment + redirection 1s
- Liens d'activation forcés vers paie.odentas.fr (tests depuis localhost)
- Messages UI cohérents : 'Invitation envoyée' au lieu de 'Compte créé'
2025-11-14 17:41:46 +01:00

25 KiB

🍓 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

// 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é

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

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

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

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<Buffer> {
  
  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<string, any>;
}

4. Service d'Horodatage TSA

Option A : FreeTSA (Gratuit)

// /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<Buffer> {
  // 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)

// /lib/tsa/universign.ts
export async function getUniversignTimestamp(pdfHash: Buffer): Promise<Buffer> {
  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

-- 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

# 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

# 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

# .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

# 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 ? 🍓