0. Quickstart — dal nulla alla prima segnalazione
Due modi di integrare, stesso back-end zero-knowledge. Server-to-server → API; zero codice crypto → Widget (iframe).
Onboarding API (server-to-server)
# 1) Registrati → "chiave madre" (scope tenants:provision), mostrata UNA volta
curl -sS -X POST https://api.vocea.cloud/v1/integrations/register \
-H "Content-Type: application/json" \
-d '{ "name":"ACME HR", "slug":"acme", "contact_email":"dev@acme.it" }'
# → { "api_key":"wbk_…", "scopes":["tenants:provision"] }
# 2) Crea il canale di un'azienda cliente → ottieni la sua API key dedicata
curl -sS -X POST https://api.vocea.cloud/v1/integrations/tenants \
-H "X-API-Key: <CHIAVE_MADRE>" -H "Content-Type: application/json" \
-d '{ "name":"Cliente S.p.A.", "slug":"clientespa" }'
# → { "tenant":{"slug":"acme-clientespa"}, "api_key":"wbk_…", "scopes":["reports:submit","reports:read","status:read"] }
# 3) Leggi le chiavi pubbliche del canale (per cifrare) GET /api/wb/tenants/{slug}/info
# 4) Invia una segnalazione cifrata (X-API-Key = chiave del canale)
# POST /v1/integrations/reports/submit { content_ciphertext, content_iv, content_auth_tag, key_wrappers, category }
# → { anonymous_token: "WB-…" }
# 5) Stato/elenco (solo metadati) · Allegati: inline o chunked (vedi §6)
Esempi runnable: vocea-integration.js (submit) · attachments.js / attachments.py (allegati inline + chunked). La cifratura è spiegata in §5.
Widget incorporabile (zero codice crypto)
<iframe src="https://app.vocea.cloud/it/segnala/<slug>?embed=1&theme=dark"
style="width:100%;border:0" sandbox="allow-scripts allow-forms allow-same-origin"></iframe>
Eventi postMessage: vocea:ready · vocea:resize {height} · vocea:submitted (nessun dato segreto). Il tuo dominio va aggiunto all'allowlist frame-ancestors — comunicacelo. Limiti e SLA: vedi §8 e la guida docs/STARGATEWB.md.
⚠️ Anonimato — collocazione obbligatoria. Il widget garantisce l'anonimato al suo livello (zero-knowledge, IP mai salvato, nessuna traccia all'apertura), ma non può rendere anonimo un accesso che la tua pagina rende tracciabile. Il canale deve stare su una superficie pubblica senza login; il passaggio (apertura della pagina) non deve finire in audit log/analytics; nessun identificativo nell'URL dell'iframe. Direttive vincolanti + checklist go-live: docs/STARGATEWB_ANONYMITY_PLACEMENT.md.
1. Modello di fiducia
L'API Vocea è pensata per uso server-to-server. Un applicativo terzo si autentica con una API key per-tenant e può inviare segnalazioni o leggerne metadati/stato per conto del proprio canale — mai di altri.
- Multi-tenant isolato: ogni API key è legata a un singolo canale (tenant). Risolta la chiave, il gateway commuta sul database del singolo tenant: una chiave non vede mai i dati di un altro canale, né il control-plane di piattaforma.
- Zero-knowledge preservato: il contenuto delle segnalazioni viaggia e viene archiviato solo cifrato. Il server non possiede le chiavi private e non decifra mai. L'API espone metadati e, dove previsto, ciphertext — mai testo in chiaro.
- Anonimato by design: l'IP del segnalante non viene mai persistito (D.Lgs. 24/2023 art. 12). Gli audit registrano solo metadati, con IP pseudonimizzato (HMAC).
2. Architettura di sicurezza
applicativo terzo (es. lg231.agile.software)
│ HTTPS · header: X-API-Key: wbk_...
▼
┌─────────────────────────────────────────────┐
│ EDGE api.vocea.cloud (TLS 1.2+, HSTS) │ L1 — unico host che espone l'API
│ opz. IP allowlist / mTLS per client B2B │ key; sempre cifrato in transito
└──────────────────┬────────────────────────────┘
▼ 127.0.0.1:4223 (loopback)
┌─────────────────────────────────────────────┐
│ nexus-whistleblowing-ms — router /api/wb/v1 │
│ L2 apiKeyAuth(scopes) → valida hash chiave, │
│ stato tenant, apre DB-per-tenant │
│ L3 apiKeyLimiter → quota per-chiave │
│ L4 handler → submit / status / list │
│ L5 apiAudit → chi/scope/route/esito │
└─────────────────────────────────────────────┘
Il microservizio (:4223) non è mai esposto a internet: solo l'edge proxy lo raggiunge in loopback. La chiave viene confrontata per hash SHA-256 (nel DB non è mai salvata in chiaro). Ogni chiamata è tracciata su un audit con hash-chain HMAC, senza contenuti sensibili.
3. Autenticazione — API key
Tutte le chiamate /v1/integrations/* richiedono l'header:
X-API-Key: wbk_<prefix>_<secret>
Formato: prefisso wbk_ + identificativo (prefix) + segreto ad alta entropia. Nel database Vocea conserva solo l'hash SHA-256 della chiave più il prefix (per identificarla): la chiave intera è mostrata una sola volta, alla creazione.
Ciclo di vita della chiave
Le chiavi si gestiscono dalla console del gestore del canale (sezione «API & Integrazioni»), non da un admin di piattaforma:
| Operazione | Dove |
|---|---|
| Emissione (mostrata una volta) | Console gestore → «API & Integrazioni» → Crea chiave (scegli gli scope) |
| Elenco (prefix, label, scope, stato, ultimo uso) | Console gestore (la chiave non è mai più visibile) |
| Revoca | Console gestore → Revoca (immediata) |
| Rotazione | Emetti nuova → aggiorna il client → revoca la vecchia (finestra di overlap) |
4. Scope & permessi
Ogni chiave porta uno o più scope; ogni endpoint richiede lo scope minimo. Principio del privilegio minimo: assegna solo ciò che serve.
| Scope | Consente |
|---|---|
| reports:submit | Inviare una segnalazione cifrata al canale. |
| status:read | Leggere lo stato di una segnalazione tramite codice di tracking (solo metadati). |
| reports:read | Elencare i metadati delle segnalazioni del canale (+ ciphertext, mai plaintext). |
| keyholders:manage | Gestire i key holder del proprio canale (enroll/elenco/disattivazione). Solo chiave pubblica RSA (zero-knowledge). |
| delegates:manage | Gestire deleghe/gestori del proprio canale (invito/elenco). |
Gli scope di gestione sono opzionali e per-tenant: ogni applicativo amministra solo la propria organizzazione; non vede né tocca altri canali, né può creare altre API key.
5. Confine zero-knowledge — come cifrare
Vocea non decifra mai. Per inviare una segnalazione, l'integratore deve cifrare il payload client-side, prima della chiamata, con le chiavi pubbliche del canale.
5.1 — Recupera le chiavi pubbliche del canale
Endpoint pubblico (nessuna API key), per slug del canale:
GET https://vocea.cloud/api/wb/tenants/{slug}/info
Ritorna, tra l'altro, l'elenco dei key_holders attivi e — fondamentale — il compartimento che ciascuno controlla:
{
"tenant": { "id": "...", "name": "...", "slug": "acme", "default_locale": "it" },
"key_holders": [
{ "id": "kh_1", "role": "...", "controls_key": "K_content", "rsa_public_key_pem": "-----BEGIN PUBLIC KEY-----\n..." },
{ "id": "kh_2", "role": "...", "controls_key": "K_identity", "rsa_public_key_pem": "-----BEGIN PUBLIC KEY-----\n..." },
{ "id": "kh_3", "role": "...", "controls_key": "K_metadata", "rsa_public_key_pem": "-----BEGIN PUBLIC KEY-----\n..." }
],
"categories": [ ... ],
"enrollment_complete": true
}
enrollment_complete è true: significa che i key-holder (e quindi le chiavi pubbliche) sono configurati. Altrimenti il canale non può ancora ricevere segnalazioni.5.2 — Schema crittografico
La segnalazione è divisa in tre compartimenti cifrati indipendentemente, ciascuno con una chiave AES-256-GCM monouso:
- content — il testo della segnalazione (obbligatorio);
- identity — identità del segnalante, se fornita (opzionale; può essere
nullper restare anonimo); - metadata — metadati applicativi (opzionale).
Per ogni compartimento:
- genera una chiave AES-256 e un IV a 12 byte; cifra in AES-256-GCM → produce
ciphertext,iv,authTag(16 byte), tutti in base64; - incarta (wrap) la chiave AES con la chiave pubblica RSA del key-holder che controlla quel compartimento, in RSA-OAEP (SHA-256) → un elemento in
key_wrappers.
RSA-4096-OAEP(SHA-256) per incartare le chiavi · AES-256-GCM (IV 12 byte, tag 16 byte) per i dati. La chiave privata RSA risiede solo lato gestore: Vocea archivia il ciphertext e non può aprirlo.5.3 — Pseudocodice (WebCrypto)
// 1) chiavi pubbliche del canale
const info = await (await fetch(`${BASE}/api/wb/tenants/${slug}/info`)).json();
const khFor = (t) => info.key_holders.find(k => k.controls_key === t);
// 2) cifra un compartimento in AES-256-GCM + incarta la chiave in RSA-OAEP
async function sealField(plaintext, keyType) {
const aes = await crypto.subtle.generateKey({name:"AES-GCM",length:256}, true, ["encrypt"]);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(await crypto.subtle.encrypt({name:"AES-GCM",iv}, aes, enc(plaintext)));
// GCM: gli ultimi 16 byte sono l'auth tag
const authTag = ct.slice(ct.length - 16), body = ct.slice(0, ct.length - 16);
const kh = khFor(keyType);
const pub = await importRsaPublicKey(kh.rsa_public_key_pem); // SPKI
const raw = await crypto.subtle.exportKey("raw", aes);
const wrapped = await crypto.subtle.encrypt({name:"RSA-OAEP"}, pub, raw); // OAEP SHA-256
return {
field: { ciphertext: b64(body), iv: b64(iv), authTag: b64(authTag) },
wrapper: { keyholder_id: kh.id, key_type: keyType, encrypted_aes_key: b64(wrapped) },
};
}
const content = await sealField(reportText, "K_content");
const identity = anonymous ? null : await sealField(JSON.stringify(id), "K_identity");
const metadata = await sealField(JSON.stringify(meta), "K_metadata");
6. Endpoint /v1
Base URL: https://api.vocea.cloud/v1 (dominio dedicato) — in alternativa, via gateway applicativo https://vocea.cloud/api/wb/v1. Tutte richiedono X-API-Key.
POST /integrations/reports/submit reports:submit
Invia una segnalazione già cifrata (vedi §5). Restituisce il codice di tracking — l'unico modo per seguire la segnalazione: consegnalo al segnalante.
Body (campi *_ciphertext/_iv/_auth_tag e encrypted_aes_key in base64):
{
"content_ciphertext": "BASE64", "content_iv": "BASE64", "content_auth_tag": "BASE64",
"identity_ciphertext": "BASE64|null", "identity_iv": "BASE64|null", "identity_auth_tag": "BASE64|null",
"metadata_ciphertext": "BASE64", "metadata_iv": "BASE64", "metadata_auth_tag": "BASE64",
"key_wrappers": [
{ "keyholder_id": "kh_1", "key_type": "K_content", "encrypted_aes_key": "BASE64" },
{ "keyholder_id": "kh_2", "key_type": "K_identity", "encrypted_aes_key": "BASE64" },
{ "keyholder_id": "kh_3", "key_type": "K_metadata", "encrypted_aes_key": "BASE64" }
],
"category": "CORRUPTION_BRIBERY",
"subcategory_d231": null,
"submission_channel": "API"
}
curl:
curl -sS -X POST https://api.vocea.cloud/v1/integrations/reports/submit \
-H "X-API-Key: wbk_<prefix>_<secret>" \
-H "Content-Type: application/json" \
-d @report.json
201 Created:
{
"success": true,
"anonymous_token": "WB-7F3K-9QA2",
"tenant_slug": "acme",
"message": "Segnalazione ricevuta e cifrata. Conserva il codice per verificare lo stato."
}
GET /integrations/reports/{code}/status status:read
Stato di una segnalazione tramite codice di tracking. Solo metadati: nessun contenuto, nessun ciphertext.
curl -sS https://api.vocea.cloud/v1/integrations/reports/WB-7F3K-9QA2/status \
-H "X-API-Key: wbk_<prefix>_<secret>"
{
"code": "WB-7F3K-9QA2",
"status": "under_review",
"created_at": "2026-06-03T10:12:45Z",
"last_update_at": "2026-06-05T08:30:00Z",
"deadlines": {
"acknowledgment_deadline_at": "2026-06-10T10:12:45Z",
"investigation_deadline_at": "2026-09-03T10:12:45Z"
}
}
Le scadenze riflettono gli obblighi del D.Lgs. 24/2023: avviso di ricevimento entro 7 giorni, riscontro entro 3 mesi.
POST /integrations/reports/{code}/attachments reports:submit
Allega una prova cifrata (documento, immagine, audio, video) a una segnalazione. Il file si cifra lato client con AES-256-GCM usando la stessa chiave del compartimento content (già incartata nei key_wrappers al submit): nessun wrapping aggiuntivo. Nome file e mime-type viaggiano anch'essi cifrati. Al server arriva solo ciphertext → decifrabile esclusivamente dai key holder designati. Limite: 50 MB per allegato (per video di grandi dimensioni usa l'upload chunked qui sotto). Max 20 allegati per segnalazione.
curl -sS -X POST https://api.vocea.cloud/v1/integrations/reports/WB-7F3K-9QA2/attachments \
-H "X-API-Key: wbk_<prefix>_<secret>" -H "Content-Type: application/json" \
-d '{
"original_filename_encrypted": "BASE64...", "filename_iv": "BASE64_12B",
"mime_type_encrypted": "BASE64...",
"content_ciphertext": "BASE64...", "content_iv": "BASE64_12B", "content_auth_tag": "BASE64_16B"
}'
{ "success": true, "attachment_id": 12, "file_size_bytes": 348201 }
Antivirus: una scansione sul ciphertext è incompatibile con lo zero-knowledge (il server non possiede le chiavi private). La scansione avviene lato client del gestore alla decifratura; antivirus_scan_result ne registra l'esito (PENDING di default).
GET /integrations/reports/{code}/attachments reports:read
Elenco degli allegati di una segnalazione. Solo metadati (id, dimensione, esito antivirus, data): nessun ciphertext.
curl -sS https://api.vocea.cloud/v1/integrations/reports/WB-7F3K-9QA2/attachments \
-H "X-API-Key: wbk_<prefix>_<secret>"
{
"code": "WB-7F3K-9QA2",
"attachments": [
{ "id": 12, "file_size_bytes": 348201, "antivirus_scan_result": "PENDING", "antivirus_scanned_at": null, "created_at": "2026-06-24T09:52:54Z", "storage": "inline" }
]
}
Allegati GRANDI (video/audio) — upload chunked/resumable
Per file oltre i 50 MB il caricamento avviene a chunk binari (niente base64-in-JSON) ed è ripartibile (resumable). Limite per file: 2 GB (configurabile). AEAD per-chunk: poiché Web Crypto cifra l'intero buffer in un colpo, un file grande NON si può cifrare in un unico GCM nel browser → ogni chunk si cifra indipendentemente in AES-256-GCM con un IV proprio (chiave = quella del compartimento content). Il body di ogni chunk è iv(12) ‖ ciphertext ‖ tag(16). Flusso: init → PUT chunk in ordine → complete.
# 1) init: metadati cifrati (filename/mime + IV) + n. chunk + dimensione totale (plaintext)
curl -sS -X POST https://api.vocea.cloud/v1/integrations/reports/WB-7F3K-9QA2/attachments/uploads \
-H "X-API-Key: wbk_…" -H "Content-Type: application/json" \
-d '{ "original_filename_encrypted":"BASE64...", "filename_iv":"BASE64_12B",
"mime_type_encrypted":"BASE64...", "mime_iv":"BASE64_12B",
"total_chunks": 88, "total_size_bytes": 734003200 }'
# → { "success":true, "upload_id":"…", "received_chunks":0, "total_chunks":88, "chunk_max_bytes":8388608 }
# 2) PUT di ogni chunk in ordine; body = iv||ciphertext||tag (octet-stream, NO base64)
curl -sS -X PUT https://api.vocea.cloud/v1/integrations/reports/WB-7F3K-9QA2/attachments/uploads/<upload_id>/chunks/0 \
-H "X-API-Key: wbk_…" -H "Content-Type: application/octet-stream" --data-binary @chunk_0.bin
# → { "success":true, "received_chunks":1, "next_chunk_index":1 }
# 3) complete: verifica n. chunk + hash, segna pronto
curl -sS -X POST https://api.vocea.cloud/v1/integrations/reports/WB-7F3K-9QA2/attachments/uploads/<upload_id>/complete \
-H "X-API-Key: wbk_…"
# → { "success":true, "attachment_id":7, "file_size_bytes":734003200, "storage":"chunked" }
Resume: GET …/uploads/{upload_id} restituisce next_chunk_index da cui ripartire; re-inviare un indice già acquisito è un ack idempotente (duplicate:true); un indice fuori sequenza risponde 409 con next_chunk_index. Download (lato key holder): GET …/attachments/large/{id}/download trasmette in streaming un file self-describing con framing X-WB-Framing: len32-iv12-gcm — sequenza di [uint32_be len][iv‖ciphertext‖tag] per chunk; metadati cifrati negli header X-WB-Filename-Enc/IV, X-WB-Mime-Enc/IV, X-WB-Total-Chunks, X-WB-Content-Hash. Gli allegati grandi compaiono anche in GET …/attachments con "storage":"chunked" e download_path.
Esempio JS (Web Crypto) — cifratura + upload chunked
La chiave AES (aesKey, CryptoKey AES-GCM) è la stessa usata per il compartimento content al submit.
const enc = new TextEncoder();
const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
async function gcm(aesKey, data) { // → iv(12)||ct||tag
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, data);
const out = new Uint8Array(12 + ct.byteLength);
out.set(iv, 0); out.set(new Uint8Array(ct), 12);
return out; // Web Crypto appende già il tag al ct
}
async function uploadLarge(base, code, apiKey, file, aesKey) {
const CH = 8 * 1024 * 1024;
const totalChunks = Math.ceil(file.size / CH);
// metadati cifrati (filename/mime): per init servono enc + iv separati
const fnIv = crypto.getRandomValues(new Uint8Array(12));
const fnCt = await crypto.subtle.encrypt({ name:'AES-GCM', iv: fnIv }, aesKey, enc.encode(file.name));
const mtIv = crypto.getRandomValues(new Uint8Array(12));
const mtCt = await crypto.subtle.encrypt({ name:'AES-GCM', iv: mtIv }, aesKey, enc.encode(file.type || 'application/octet-stream'));
const H = { 'X-API-Key': apiKey };
// init
let r = await fetch(`${base}/v1/integrations/reports/${code}/attachments/uploads`, {
method:'POST', headers:{ ...H, 'Content-Type':'application/json' }, body: JSON.stringify({
original_filename_encrypted: b64(fnCt), filename_iv: b64(fnIv),
mime_type_encrypted: b64(mtCt), mime_iv: b64(mtIv),
total_chunks: totalChunks, total_size_bytes: file.size }) });
const { upload_id } = await r.json();
// chunk in ordine (resumable: in caso di errore rileggi next_chunk_index da GET …/uploads/{id})
for (let i = 0; i < totalChunks; i++) {
const slice = await file.slice(i*CH, Math.min((i+1)*CH, file.size)).arrayBuffer();
const body = await gcm(aesKey, slice); // iv||ct||tag del chunk
await fetch(`${base}/v1/integrations/reports/${code}/attachments/uploads/${upload_id}/chunks/${i}`, {
method:'PUT', headers:{ ...H, 'Content-Type':'application/octet-stream' }, body });
}
// complete
r = await fetch(`${base}/v1/integrations/reports/${code}/attachments/uploads/${upload_id}/complete`, { method:'POST', headers: H });
return r.json(); // { attachment_id, file_size_bytes, storage:'chunked' }
}
Alternativa: Widget incorporabile (iframe) — senza API key
Se non volete reimplementare la crittografia, incorporate il widget Vocea: è la nostra pagina pubblica di segnalazione (cifratura client-side, allegati inclusi) servita in <iframe>. Nessuna API key nel browser — il canale è instradato per slug; Vocea non vede mai il testo in chiaro. La crypto (e i suoi aggiornamenti) restano a carico di Vocea.
<iframe
src="https://app.vocea.cloud/it/segnala/<slug>?embed=1&theme=dark"
style="width:100%;border:0" title="Segnalazione"
sandbox="allow-scripts allow-forms allow-same-origin"></iframe>
Il vostro dominio va aggiunto all'allowlist frame-ancestors di Vocea (CSP) — comunicatecelo. Parametri: ?embed=1 (vista senza header), ?theme=dark (tema scuro), locale nel path (/it).
Eventi postMessage verso la pagina ospitante (filtrare su data.source==='vocea'):
window.addEventListener('message', (e) => {
if (e.data?.source !== 'vocea') return;
switch (e.data.type) {
case 'vocea:ready': /* widget pronto */ break;
case 'vocea:resize': iframe.style.height = e.data.height + 'px'; break; // auto-resize
case 'vocea:submitted': console.log('codice segnalazione', e.data.code); break; // WB-…
}
});
GET /integrations/reports reports:read
Elenco paginato dei metadati del canale. Query opzionali: page (default 1), pageSize (1–100, default 20), status. Gli item includono metadati e ciphertext, mai testo in chiaro: la decifratura avviene solo lato gestore.
curl -sS "https://api.vocea.cloud/v1/integrations/reports?page=1&pageSize=20&status=under_review" \
-H "X-API-Key: wbk_<prefix>_<secret>"
{ "items": [ /* metadati + ciphertext */ ], "page": 1, "pageSize": 20, "total": 42 }
Gestione del proprio canale (self-service)
Ogni applicativo amministra la propria organizzazione: key holder e deleghe del proprio canale. Richiede gli scope dedicati; isolato per-tenant.
POST /integrations/keyholders keyholders:manage
Enrolla un key holder. La coppia RSA si genera lato client: si invia solo la chiave pubblica (zero-knowledge). Un solo key holder attivo per controls_key.
POST /v1/integrations/keyholders
{
"name": "Mario Rossi", "email": "odv@azienda.it",
"role": "ODV_MEMBER", // ODV_MEMBER | LEGAL_RPD | COMPLIANCE | EXTERNAL_AUDITOR
"controls_key": "K_content", // K_content | K_identity | K_metadata
"rsa_public_key_pem": "-----BEGIN PUBLIC KEY-----\n...",
"password": "opz. — per il portale key holder; se assente è generata"
}
→ 201 { "success": true, "id": 4, "controls_key": "K_content", "role": "ODV_MEMBER" }
GET /integrations/keyholders elenca · DELETE /integrations/keyholders/{id} disattiva. Errori: 409 KEY_TYPE_TAKEN (tipo già occupato → disattiva prima), 409 EMAIL_TAKEN, 400 INVALID_PUBLIC_KEY.
POST /integrations/delegates delegates:manage
Invita un delegato/gestore del canale (ritorna un token di invito).
POST /v1/integrations/delegates
{ "email": "gestore@azienda.it", "name": "Anna Bianchi", "role": "GESTORE" } // GESTORE | DELEGATO_CREAZIONE
→ 201 { "success": true, "id": 1, "invite_token": "wbi_…" }
GET /integrations/delegates elenca le deleghe del canale.
7. Codici di errore
Tutti gli errori hanno forma { "error": { "code": "...", "message": "..." } }.
| HTTP | code | Significato |
|---|---|---|
| 400 | MISSING_FIELDS / MISSING_KEY_WRAPPERS / INVALID_CATEGORY | Body malformato o incompleto. |
| 401 | API_KEY_REQUIRED | Header X-API-Key assente. |
| 401 | API_KEY_INVALID | Chiave non valida o revocata. |
| 403 | INSUFFICIENT_SCOPE | La chiave non ha lo scope richiesto dall'endpoint. |
| 403 | TENANT_SUSPENDED | Canale sospeso. |
| 404 | TENANT_NOT_FOUND | Canale non disponibile (eliminato / in provisioning). |
| 404 | REPORT_NOT_FOUND | Nessuna segnalazione per il codice indicato in questo canale. |
| 429 | RATE_LIMIT_EXCEEDED | Quota per-chiave superata (vedi §8). |
8. Rate limiting
La quota è applicata per chiave (non per IP): un client legittimo dietro NAT non viene penalizzato, e una chiave abusiva non aggira il limite ruotando IP. Default 60 richieste/minuto.
Ogni risposta porta gli header standard RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset. Su 429, ritenta con backoff esponenziale rispettando RateLimit-Reset.
9. Hardening per integrazioni B2B
- TLS sempre: chiama esclusivamente in HTTPS (TLS 1.2+). Verifica il certificato del server; non disabilitare la verifica.
- IP allowlist (opzionale, per-chiave): si può vincolare una chiave a una lista di CIDR. Consigliato per integrazioni con IP di uscita stabili.
- mTLS (opzionale, enterprise): il vhost
api.vocea.cloudpuò richiedere certificato client per i partner che lo necessitano. - Niente CORS da browser: l'API è server-to-server. Non chiamarla da JavaScript di pagina: esporresti la chiave.
- Rotazione: ruota le chiavi periodicamente e dopo ogni sospetto incidente; revoca subito le chiavi inutilizzate.
- Minimo contenuto sensibile in chiaro lato tuo: cifra il prima possibile; non loggare ciphertext né payload.
10. Checklist sicurezza per l'integratore
- Chiave ottenuta dalla console gestore con i soli scope necessari.
- Chiave conservata in un vault/secret manager, mai in git/env committate.
- Chiamate solo server-side, sempre in HTTPS con verifica del certificato.
- Payload cifrato client-side (AES-256-GCM + RSA-OAEP) prima del submit; nessun plaintext inviato.
- Chiavi pubbliche del canale recuperate da
/tenants/{slug}/infoe usate per ogni compartimento. - Codice di tracking
anonymous_tokenconsegnato al segnalante e non archiviato in chiaro insieme a dati identificativi. - Gestione di
429con backoff; gestione esplicita di401/403. - Procedura di rotazione/revoca chiavi documentata e testata.