SIP Credentials Storage — Correzione cap. 12.5 (v5.15 → v5.16)
Stato: Decisione architetturale consolidata Owner: Ingegnere TLC Akira Data: 2026-05-13 Riferimento docx: cap. 12.5 v5.15 (da correggere in v5.16)
1. Problema identificato
Il cap. 12.5 del docx v5.15 prescrive di memorizzare la password SIP delle device "bcrypt/argon2 in DB sicuro". Questa indicazione è tecnicamente errata nel contesto SIP Digest Authentication.
bcrypt e argon2 sono hash a senso unico, progettati per verificare una password fornita in chiaro contro un hash storato. SIP Digest non funziona così: il client non manda mai la password in chiaro al server, e il server deve poter ricalcolare runtime un valore di confronto a partire da un segreto condiviso.
2. Razionale tecnico (RFC 8760, RFC 7616)
SIP Digest auth flow (semplificato):
- Client invia
REGISTERoINVITE. - Server risponde
401 Unauthorizedconnoncerandom +realm. - Client calcola:
HA1 = MD5(username:realm:password)HA2 = MD5(method:uri)response = MD5(HA1:nonce:nc:cnonce:qop:HA2)
- Client invia di nuovo la richiesta con header
Authorizationcontenenteresponse. - Server deve calcolare lo stesso
responseper validare.
Per il punto 5 il server deve avere HA1 in chiaro (o in forma reversibile). Se HA1 fosse hashato con bcrypt/argon2, il server non potrebbe più ricalcolarlo e l'autenticazione fallirebbe sempre.
Nota: HA1 = MD5(username:realm:password) è già di per sé un hash della
password, ma è un hash deterministico e funge da segreto condiviso, non
da hash di verifica.
3. Decisione Akira (v5.16)
| Aspetto | Decisione |
|---|---|
| Cosa si memorizza | HA1 (non la password plaintext) |
| Algoritmo at-rest | AES-256-GCM (cifratura reversibile) |
| Chiave | ENCRYPTION_KEY env var, 32 byte hex |
| Plaintext password | MAI persistita, mai loggata |
| Tabella | devices, colonna sip_ha1_encrypted BYTEA |
| Realm | devices.sip_realm, default akira.asheep.it |
Razionale chiave reversibile: HA1 è un segreto condiviso, non un verifier. La cifratura at-rest serve a proteggere il dump del DB / backup; il rischio è mitigato dal fatto che la chiave AES vive in env del processo, non in DB.
4. Schema SQL
-- Rimuovi colonna errata da cap. 12.5 v5.15
ALTER TABLE devices DROP COLUMN IF EXISTS sip_password_encrypted;
-- Nuova colonna corretta
ALTER TABLE devices ADD COLUMN sip_ha1_encrypted BYTEA;
ALTER TABLE devices ADD COLUMN sip_realm VARCHAR(128) DEFAULT 'akira.asheep.it';
-- Estensione pgcrypto richiesta (già installata in tier app DB)
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Funzione SECURITY DEFINER: solo chi ha EXECUTE può decifrare,
-- senza dover avere SELECT sulla chiave
CREATE OR REPLACE FUNCTION fn_decrypt_ha1(p_encrypted BYTEA, p_key TEXT)
RETURNS TEXT
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
RETURN convert_from(pgp_sym_decrypt_bytea(p_encrypted, p_key), 'UTF8');
END $$;
REVOKE ALL ON FUNCTION fn_decrypt_ha1(BYTEA, TEXT) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION fn_decrypt_ha1(BYTEA, TEXT) TO akira_kamailio;
-- View per modulo auth_db Kamailio
CREATE OR REPLACE VIEW kamailio_subscriber_view AS
SELECT
d.id,
d.sip_username AS username,
d.sip_realm AS realm,
fn_decrypt_ha1(d.sip_ha1_encrypted, current_setting('app.encryption_key')) AS ha1
FROM devices d
WHERE d.is_active = TRUE
AND d.host_type = 'dynamic'
AND d.sip_ha1_encrypted IS NOT NULL;
GRANT SELECT ON kamailio_subscriber_view TO akira_kamailio;
Il current_setting('app.encryption_key') viene impostato dal pooler/sidecar
all'apertura della connessione (SET LOCAL app.encryption_key = '...'). In
questo modo Kamailio non vede mai la chiave AES direttamente: la pone come
session GUC e la funzione SECURITY DEFINER la usa per decifrare inline.
5. Architettura accessi
Due opzioni valutate:
Opzione A — Sidecar decryption (scartata per MVP)
Microservizio FastAPI espone REST POST /resolve-ha1?username=...&realm=...
e Kamailio interroga via http_async_client. Pro: separation of concerns,
chiave AES isolata in un container. Contro: latency aggiuntiva (>5ms per
ogni auth challenge), single point of failure, codice in più da mantenere.
Opzione B — View pgcrypto + GUC (scelta) ✓
kamailio_subscriber_view con funzione SECURITY DEFINER. Kamailio
auth_db legge la view come se fosse una tabella subscriber standard.
Pro: zero codice custom, latency sub-ms (single query), Kamailio non vede
la chiave AES. Contro: la chiave passa come session GUC — accettabile se
l'utente DB di Kamailio è isolato (akira_kamailio) e la connessione è
SSL su Tailscale.
Decisione: Opzione B. Documentata in roles/kamailio Ansible playbook.
6. Procedura update password — admin UI flow
- Admin apre form Device → SIP Credentials.
- UI mostra
sip_username(read-only),sip_realm(read-only, da config), campo password vuoto con placeholder••••••••(no leakage di valore esistente). - Admin inserisce password plaintext, click Save.
- Frontend invia POST
/api/v1/devices/{id}/credentialscon body:Connessione HTTPS, TLS 1.3.{"password": "..."} - Backend FastAPI:
import hashlibfrom cryptography.hazmat.primitives.ciphers.aead import AESGCMimport osha1 = hashlib.md5(f"{device.sip_username}:{device.sip_realm}:{payload.password}".encode("utf-8")).hexdigest()key = bytes.fromhex(os.environ["ENCRYPTION_KEY"])aesgcm = AESGCM(key)nonce = os.urandom(12)ct = aesgcm.encrypt(nonce, ha1.encode("utf-8"), None)sip_ha1_encrypted = nonce + ct # prepend nonce per decrypt
UPDATE devices SET sip_ha1_encrypted = :ct WHERE id = :id.- Plaintext password scartata subito (variabile out-of-scope). MAI:
- persistita in DB,
- loggata (logger config blocklist sul campo
password), - inviata a Telegram/email,
- inclusa in audit trail (audit trail registra solo "credentials rotated
at
by <admin_user>").
- Risposta API:
204 No Content. UI conferma "Password updated".
7. Verifica e operations
- Test integrazione: Kamailio
kamcli mi auth_db.dump_subscriberdeve ritornare HA1 in chiaro per device dynamic. - Key rotation: nuova
ENCRYPTION_KEY→ job batchrotate_ha1_keysdecifra con vecchia, ricifra con nuova, atomic in transaction. Procedure in runbookops/key-rotation.md(v5.16 TBD). - Backup DB: backup logici (
pg_dump) contengonosip_ha1_encryptedcifrato — la chiave AES NON è nel backup. Restore richiede chiave AES separata da vault Ansible.
8. Note di sicurezza residue
- MD5 è debole crittograficamente, ma è imposto da RFC SIP Digest. RFC 8760
permette SHA-256/SHA-512-256 → roadmap v2 (vedi
signaling-decisions.md). - AES-GCM richiede nonce univoco per coppia chiave/plaintext: generato random 12 byte, probabilità collisione trascurabile (<2^-32 a 10^9 records).
- Compromise del DB + env del backend = compromise totale credenziali.
Mitigation: env in Ansible vault, accesso SSH con MFA, audit log su
fn_decrypt_ha1calls.
9. Patch da applicare a docx v5.16
- Cap. 12.5: sostituire integralmente sezione "Storage password SIP". Nuovo testo basato su questo doc.
- Cap. 9.11 (Devices schema): aggiungere colonne
sip_ha1_encrypted,sip_realm. Rimuovere riferimentosip_password_encrypted. - Cap. 25 (Security): aggiungere paragrafo "Encryption-at-rest per credenziali
SIP" con riferimento a
ENCRYPTION_KEYenv e procedura key rotation. - Glossario: aggiungere voce HA1.