Ontwikkelaar zijn PyGOST bibliotheken (GOST cryptografische primitieven in pure Python), krijg ik vaak vragen over hoe ik de eenvoudigste veilige berichtenuitwisseling op de knie kan implementeren. Veel mensen beschouwen toegepaste cryptografie als vrij eenvoudig, en het aanroepen van .encrypt() op een blokcode zal voldoende zijn om het veilig via een communicatiekanaal te verzenden. Anderen geloven dat toegepaste cryptografie het lot van weinigen is, en dat het acceptabel is dat rijke bedrijven als Telegram met olympiade-wiskundigen kan niet implementeren beveiligd protocol.
Dit alles bracht mij ertoe dit artikel te schrijven om te laten zien dat het implementeren van cryptografische protocollen en veilige IM niet zo'n moeilijke taak is. Het is echter niet de moeite waard om uw eigen authenticatie- en sleutelovereenkomstprotocollen te bedenken.
Eerst moeten we begrijpen hoe onze IM eruit zal zien. Laat het voor de eenvoud een peer-to-peer-netwerk zijn, zonder enige ontdekking van deelnemers. Wij zullen persoonlijk aangeven op welk adres: poort we verbinding moeten maken om met de gesprekspartner te communiceren.
Ik begrijp dat op dit moment de veronderstelling dat directe communicatie beschikbaar is tussen twee willekeurige computers een aanzienlijke beperking vormt voor de toepasbaarheid van IM in de praktijk. Maar hoe meer ontwikkelaars allerlei soorten NAT-traversal-krukken implementeren, hoe langer we op het IPv4-internet zullen blijven, met een deprimerende kans op communicatie tussen willekeurige computers. Hoe lang kun je het gebrek aan IPv6 thuis en op het werk tolereren?
We zullen een vriend-tot-vriend-netwerk hebben: alle mogelijke gesprekspartners moeten vooraf bekend zijn. Ten eerste vereenvoudigt dit alles enorm: we hebben onszelf voorgesteld, de naam/sleutel gevonden of niet gevonden, de verbinding verbroken of verder gewerkt, terwijl we de gesprekspartner kenden. Ten tweede is het over het algemeen veilig en elimineert het veel aanvallen.
De IM-interface zal dicht bij klassieke oplossingen liggen waardeloze projecten, wat ik erg leuk vind vanwege hun minimalisme en Unix-filosofie. Het IM-programma maakt voor elke gesprekspartner een directory aan met drie Unix-domeinsockets:
in: berichten die naar de gesprekspartner worden verzonden, worden erin opgenomen;
uit - berichten ontvangen van de gesprekspartner worden ervan gelezen;
staat - door ervan te lezen, komen we erachter of de gesprekspartner momenteel verbonden is, het verbindingsadres/poort.
Bovendien wordt er een conn-socket gemaakt door de hostpoort te schrijven waarin we een verbinding met de externe gesprekspartner initiΓ«ren.
|-- alice
| |-- in
| |-- out
| `-- state
|-- bob
| |-- in
| |-- out
| `-- state
`- conn
Met deze aanpak kunt u onafhankelijke implementaties van IM-transport en gebruikersinterface maken, omdat er geen vriend is en u niet iedereen tevreden kunt stellen. Gebruik makend van tmux en / of meerstaartig, kunt u een interface met meerdere vensters krijgen met syntaxisaccentuering. En met de hulp rlwrap u kunt een GNU Readline-compatibele berichtinvoerregel krijgen.
In feite gebruiken suckless-projecten FIFO-bestanden. Persoonlijk kon ik niet begrijpen hoe ik competitief in asyncio met bestanden moest werken zonder een handgeschreven achtergrond uit speciale threads (ik gebruik de taal al heel lang voor zulke dingen Go). Daarom besloot ik het te doen met Unix-domeinsockets. Helaas maakt dit het onmogelijk om echo 2001:470:dead::babe 6666 > conn. Ik heb dit probleem opgelost met behulp van sokat: echo 2001:470:dood::schat 6666 | socat - UNIX-CONNECT:conn, socat READLINE UNIX-CONNECT:alice/in.
Het originele, onveilige protocol
TCP wordt gebruikt als transportmiddel: het garandeert de levering en de bestelling ervan. UDP garandeert geen van beide (wat handig zou zijn als cryptografie wordt gebruikt), maar ondersteuning SCTP Python komt niet uit de doos.
Helaas is er in TCP geen concept van een bericht, alleen een stroom bytes. Daarom is het noodzakelijk om een ββformaat voor berichten te bedenken, zodat ze in deze thread onderling kunnen worden gedeeld. We kunnen afspreken om het line feed-teken te gebruiken. Om te beginnen is dat prima, maar zodra we onze berichten gaan versleutelen, kan dit teken overal in de cijfertekst voorkomen. In netwerken zijn daarom populaire protocollen de protocollen die eerst de lengte van het bericht in bytes verzenden. Python beschikt bijvoorbeeld standaard over xdrlib, waarmee je met een vergelijkbaar formaat kunt werken XDR.
We zullen niet correct en efficiΓ«nt werken met TCP-lezen - we zullen de code vereenvoudigen. We lezen gegevens uit de socket in een eindeloze lus totdat we het volledige bericht decoderen. Ook JSON met XML kan als format voor deze aanpak worden gebruikt. Maar wanneer cryptografie wordt toegevoegd, moeten de gegevens worden ondertekend en geauthenticeerd - en dit vereist een byte-voor-byte identieke representatie van objecten, wat JSON/XML niet biedt (dumpresultaten kunnen variΓ«ren).
XDR is geschikt voor deze taak, maar ik kies voor ASN.1 met DER-codering en PyDERASN bibliotheek, omdat we objecten van hoog niveau bij de hand hebben waarmee het vaak prettiger en gemakkelijker is om te werken. In tegenstelling tot schemaloos Bencode, Berichtenpakket of CBOR, zal ASN.1 de gegevens automatisch controleren aan de hand van een hardgecodeerd schema.
Stel je eigen naam in (--onze-naam Alice). Alle verwachte gesprekspartners worden vermeld, gescheiden door komma's (βhun naam bob,eve). Voor elk van de gesprekspartners wordt een map met Unix-sockets gemaakt, evenals een coroutine voor elke in, uit, staat:
Bij het lezen van een statussocket zoekt het programma naar het adres van de gesprekspartner in het PEER_ALIVE-woordenboek. Als er nog geen verbinding is met de gesprekspartner, wordt een lege regel geschreven.
async def unixsock_state_processor(reader, writer, peer_name: str) -> None:
peer_writer = PEER_ALIVES.get(peer_name)
writer.write(
b"" if peer_writer is None else (" ".join([
str(i) for i in peer_writer.get_extra_info("peername")[:2]
]).encode("utf-8") + b"n")
)
await writer.drain()
writer.close()
Wanneer een adres naar een conn-socket wordt geschreven, wordt de functie βinitiatorβ van de verbinding gestart:
async def unixsock_conn_processor(reader, writer) -> None:
data = await reader.read(256)
writer.close()
host, port = data.decode("utf-8").split(" ")
await initiator(host=host, port=int(port))
Laten we eens kijken naar de initiatiefnemer. Eerst opent het uiteraard een verbinding met de opgegeven host/poort en verzendt het een handshake-bericht met zijn naam:
Wij controleren of de ontvangen naam van de gesprekspartner bij ons bekend is. Zo niet, dan verbreken we de verbinding. We controleren of we al een verbinding met hem tot stand hebben gebracht (de gesprekspartner gaf opnieuw het bevel om verbinding met ons te maken) en sluiten deze af. De wachtrij IN_QUEUES bevat Python-tekenreeksen met de tekst van het bericht, maar heeft een speciale waarde van Geen die de coroutine van msg_sender signaleert om te stoppen met werken, zodat deze de schrijver vergeet die is gekoppeld aan de oude TCP-verbinding.
159 msg_handshake = msg.value
160 peer_name = str(msg_handshake["peerName"])
161 if peer_name not in THEIR_NAMES:
162 logging.warning("unknown peer name: %s", peer_name)
163 writer.close()
164 return
165 logging.info("%s: session established: %s", _id, peer_name)
166 # Run text message sender, initialize transport decoder {{{
167 peer_alive = PEER_ALIVES.pop(peer_name, None)
168 if peer_alive is not None:
169 peer_alive.close()
170 await IN_QUEUES[peer_name].put(None)
171 PEER_ALIVES[peer_name] = writer
172 asyncio.ensure_future(msg_sender(peer_name, writer))
173 # }}}
msg_sender accepteert uitgaande berichten (in de wachtrij geplaatst vanuit een in-socket), serialiseert ze in een MsgText-bericht en verzendt ze via een TCP-verbinding. Het kan elk moment kapot gaan - we onderscheppen dit duidelijk.
async def msg_sender(peer_name: str, writer) -> None:
in_queue = IN_QUEUES[peer_name]
while True:
text = await in_queue.get()
if text is None:
break
writer.write(Msg(("text", MsgText((
("text", UTF8String(text)),
)))).encode())
try:
await writer.drain()
except ConnectionResetError:
del PEER_ALIVES[peer_name]
return
logging.info("%s: sent %d characters message", peer_name, len(text))
174 buf = b""
175 # Wait for test messages {{{
176 while True:
177 data = await reader.read(MaxMsgLen)
178 if data == b"":
179 break
180 buf += data
181 if len(buf) > MaxMsgLen:
182 logging.warning("%s: max buffer size exceeded", _id)
183 break
184 try:
185 msg, tail = Msg().decode(buf)
186 except ASN1Error:
187 continue
188 buf = tail
189 if msg.choice != "text":
190 logging.warning("%s: unexpected %s message", _id, msg.choice)
191 break
192 try:
193 await msg_receiver(msg.value, peer_name)
194 except ValueError as err:
195 logging.warning("%s: %s", err)
196 break
197 # }}}
198 logging.info("%s: disconnecting: %s", _id, peer_name)
199 IN_QUEUES[peer_name].put(None)
200 writer.close()
66 async def msg_receiver(msg_text: MsgText, peer_name: str) -> None:
67 text = str(msg_text["text"])
68 logging.info("%s: received %d characters message", peer_name, len(text))
69 await OUT_QUEUES[peer_name].put(text)
Laten we terugkeren naar de hoofdcode. Nadat we alle coroutines hebben aangemaakt op het moment dat het programma start, starten we de TCP-server. Voor elke tot stand gebrachte verbinding wordt een respondercoroutine gecreΓ«erd.
logging.basicConfig(
level=logging.INFO,
format="%(levelname)s %(asctime)s: %(funcName)s: %(message)s",
)
loop = asyncio.get_event_loop()
server = loop.run_until_complete(asyncio.start_server(responder, args.bind, args.port))
logging.info("Listening on: %s", server.sockets[0].getsockname())
loop.run_forever()
Het is tijd om onze communicatie te beveiligen. Wat verstaan ββwij onder veiligheid en wat willen wij:
vertrouwelijkheid van verzonden berichten;
authenticiteit en integriteit van verzonden berichten - hun wijzigingen moeten worden gedetecteerd;
bescherming tegen replay-aanvallen - het feit van ontbrekende of herhaalde berichten moet worden gedetecteerd (en we besluiten de verbinding te verbreken);
identificatie en authenticatie van gesprekspartners met behulp van vooraf ingevoerde openbare sleutels - we hebben al eerder besloten dat we een vriend-tot-vriend-netwerk aan het maken waren. Pas na authenticatie zullen we begrijpen met wie we communiceren;
Π½Π°Π»ΠΈΡΠΈΠ΅ perfecte voorwaartse geheimhouding Properties (PFS) - het compromitteren van onze langlevende ondertekeningssleutel mag niet leiden tot de mogelijkheid om alle eerdere correspondentie te lezen. Het opnemen van onderschept verkeer wordt nutteloos;
een passieve waarnemer mag geen gebruikersidentificaties, overgedragen openbare sleutels met een lange levensduur of hashes daarvan zien. Een zekere anonimiteit van een passieve waarnemer.
Verrassend genoeg wil bijna iedereen dit minimum in elk handdrukprotocol hebben, en bij protocollen van eigen bodem wordt uiteindelijk maar heel weinig van het bovenstaande gehaald. Nu zullen we niets nieuws bedenken. Ik zou het zeker aanraden om te gebruiken Ruis raamwerk voor het bouwen van protocollen, maar laten we iets eenvoudiger kiezen.
De twee meest populaire protocollen zijn:
TLS - een zeer complex protocol met een lange geschiedenis van bugs, stijlen, kwetsbaarheden, slecht nadenken, complexiteit en tekortkomingen (dit heeft echter weinig te maken met TLS 1.3). Maar we denken er niet over na, omdat het te ingewikkeld is.
Iedereen kan in het midden springen en openbare sleutels vervangen door zijn eigen sleutels - er is geen authenticatie van gesprekspartners in dit protocol. Laten we een handtekening toevoegen met sleutels met een lange levensduur.
Een dergelijke handtekening zal niet werken, omdat deze niet aan een specifieke sessie gebonden is. Dergelijke berichten zijn ook βgeschiktβ voor sessies met andere deelnemers. De gehele context moet zich abonneren. Dit dwingt ons om ook nog een bericht van A.
Bovendien is het van cruciaal belang om uw eigen identificatienummer onder de handtekening toe te voegen, omdat we anders IdXXX kunnen vervangen en het bericht opnieuw kunnen ondertekenen met de sleutel van een andere bekende gesprekspartner. Voorkomen reflectie aanvallen, is het noodzakelijk dat de elementen onder de handtekening zich op duidelijk gedefinieerde plaatsen bevinden, afhankelijk van hun betekenis: als A tekent (PubA, PubB), dan moet B tekenen (PubB, PubA). Dit spreekt ook over het belang van het kiezen van de structuur en het formaat van geserialiseerde gegevens. Sets in ASN.1 DER-codering worden bijvoorbeeld gesorteerd: SET OF(PubA, PubB) zal identiek zijn aan SET OF(PubB, PubA).
We hebben echter nog steeds niet βbewezenβ dat we voor deze sessie dezelfde gedeelde sleutel hebben gegenereerd. In principe kunnen we het zonder deze stap stellen: de allereerste transportverbinding is ongeldig, maar we willen dat we er zeker van zijn dat alles echt is afgesproken als de handdruk is voltooid. Op dit moment hebben wij het ISO/IEC IS 9798-3 protocol bij de hand.
We kunnen de gegenereerde sleutel zelf ondertekenen. Dit is gevaarlijk, omdat het mogelijk is dat er lekken zijn in het gebruikte handtekeningalgoritme (hoewel bits per handtekening, maar nog steeds lekken). Het is mogelijk om een ββhash van de afleidingssleutel te ondertekenen, maar zelfs het lekken van de hash van de afgeleide sleutel kan waardevol zijn bij een brute-force aanval op de afleidingsfunctie. SIGMA gebruikt een MAC-functie die de afzender-ID verifieert.
Als optimalisatie willen sommigen misschien hun kortstondige sleutels hergebruiken (wat natuurlijk jammer is voor PFS). We hebben bijvoorbeeld een sleutelpaar gegenereerd, geprobeerd verbinding te maken, maar TCP was niet beschikbaar of werd ergens midden in het protocol onderbroken. Het is zonde om verspilde entropie- en processorbronnen te verspillen aan een nieuw paar. Daarom zullen we de zogenaamde cookie introduceren - een pseudo-willekeurige waarde die bescherming biedt tegen mogelijke willekeurige herhalingsaanvallen bij het hergebruiken van kortstondige openbare sleutels. Door de binding tussen de cookie en de kortstondige publieke sleutel kan de publieke sleutel van de tegenpartij als onnodig uit de handtekening worden verwijderd.
Tenslotte willen wij de privacy van onze gesprekspartners verkrijgen van een passieve waarnemer. Om dit te doen, stelt SIGMA voor om eerst kortstondige sleutels uit te wisselen en een gemeenschappelijke sleutel te ontwikkelen waarop authenticatie- en identificatieberichten kunnen worden gecodeerd. SIGMA beschrijft twee opties:
SIGMA-I - beschermt de initiator tegen actieve aanvallen, de responder tegen passieve aanvallen: de initiator authenticeert de responder en als iets niet overeenkomt, geeft hij zijn identificatie niet door. De verdachte geeft zijn identificatie af als er een actief protocol bij hem wordt gestart. De passieve waarnemer leert niets;
SIGMA-R - beschermt de responder tegen actieve aanvallen, de initiator tegen passieve aanvallen. Alles is precies het tegenovergestelde, maar in dit protocol worden al vier handshake-berichten verzonden.
We kiezen voor SIGMA-I omdat het meer lijkt op wat we verwachten van client-server vertrouwde dingen: de client wordt alleen herkend door de geauthenticeerde server en iedereen kent de server al. Bovendien is het eenvoudiger te implementeren omdat er minder handshake-berichten zijn. Het enige dat we aan het protocol toevoegen, is een deel van het bericht versleutelen en de identificatie A overbrengen naar het versleutelde deel van het laatste bericht:
GOST R wordt gebruikt voor ondertekening 34.10-2012 algoritme met 256-bit sleutels.
Voor het genereren van de publieke sleutel wordt gebruik gemaakt van 34.10-2012 VKO.
CMAC wordt gebruikt als MAC. Technisch gezien is dit een speciale werkingsmodus van een blokcijfer, beschreven in GOST R 34.13-2015. Als coderingsfunctie voor deze modus β sprinkhaan (34.12-2015).
De hash van zijn publieke sleutel wordt gebruikt als identificatiemiddel van de gesprekspartner. Gebruikt als hasj Stribog-256 (34.11-2012-256 XNUMX bits).
HandshakeTBS is wat zal worden ondertekend. HandshakeTBE - wat wordt gecodeerd. Ik vestig uw aandacht op het ukm-veld in MsgHandshake1. 34.10 VKO bevat, voor een nog grotere randomisatie van de gegenereerde sleutels, de UKM-parameter (user keying material) - alleen maar extra entropie.
Cryptografie aan code toevoegen
Laten we alleen de wijzigingen bekijken die in de originele code zijn aangebracht, aangezien het raamwerk hetzelfde bleef (in feite werd eerst de definitieve implementatie geschreven en daarna werd alle cryptografie eruit gesneden).
Omdat de authenticatie en identificatie van gesprekspartners zal plaatsvinden met behulp van publieke sleutels, moeten deze nu ergens voor langere tijd worden opgeslagen. Voor de eenvoud gebruiken we JSON als volgt:
Na het lezen van het JSON-bestand moeten de openbare sleutels dienovereenkomstig worden teruggeconverteerd met behulp van gost3410.pub_unmarshal(). Omdat we de identificatiegegevens van de gesprekspartners ontvangen in de vorm van een hash van de openbare sleutel, kunnen ze onmiddellijk vooraf worden berekend en in een woordenboek worden geplaatst voor snel zoeken. Stribog-256 hash is gost34112012256.GOST34112012256(), die volledig voldoet aan de hashlib-interface van hash-functies.
Hoe is de initiatiefnemer coroutine veranderd? Alles verloopt volgens het handshake-schema: we genereren een cookie (128-bit is voldoende), een kortstondig sleutelpaar 34.10, dat zal worden gebruikt voor de VKO-sleutelovereenkomstfunctie.
UKM is een 64-bits getal (urandom(8)), dat ook deserialisatie vereist van zijn byterepresentatie met behulp van gost3410_vko.ukm_unmarshal(). VKO-functie voor 34.10-2012-256 3410-bit is gost34102012256_vko.kek_XNUMX() (KEK - coderingssleutel).
De gegenereerde sessiesleutel is al een pseudo-willekeurige bytereeks van 256 bits. Daarom kan het onmiddellijk worden gebruikt in HKDF-functies. Omdat GOST34112012256 voldoet aan de hashlib-interface, kan het onmiddellijk worden gebruikt in de Hkdf-klasse. We specificeren de salt (het eerste argument van Hkdf) niet, omdat de gegenereerde sleutel, vanwege de kortstondigheid van de deelnemende sleutelparen, voor elke sessie anders zal zijn en al voldoende entropie bevat. kdf.expand() produceert standaard al de 256-bit sleutels die later nodig zijn voor Grasshopper.
Vervolgens worden de TBE- en TBS-delen van het binnenkomende bericht gecontroleerd:
de MAC over de binnenkomende cijfertekst wordt berekend en gecontroleerd;
de cijfertekst wordt gedecodeerd;
TBE-structuur wordt gedecodeerd;
daaruit wordt de identifier van de gesprekspartner gehaald en gecontroleerd of hij ΓΌberhaupt bij ons bekend is;
MAC via deze identificatie wordt berekend en gecontroleerd;
de handtekening over de TBS-structuur wordt geverifieerd, inclusief de cookie van beide partijen en de openbare kortstondige sleutel van de andere partij. De handtekening wordt geverifieerd door de langlevende handtekeningsleutel van de gesprekspartner.
Kuznechik wordt geΓ―nitialiseerd door gost.3412.GOST3412Kuznechik(key) aan te roepen en retourneert een object met .encrypt()/.decrypt()-methoden die geschikt zijn om door te geven aan 34.13-functies. MAC wordt als volgt berekend: gost3413.mac(GOST3412Kuznechik(sleutel).encrypt, KUZNECHIK_BLOCKSIZE, cijfertekst). Om de berekende en ontvangen MAC te vergelijken, kunt u niet de gebruikelijke vergelijking (==) van bytestrings gebruiken, aangezien deze bewerking vergelijkingstijd lekt, wat in het algemeen kan leiden tot fatale kwetsbaarheden zoals BEAST aanvallen op TLS. Python heeft hiervoor een speciale functie, hmac.compare_digest.
Het verifiΓ«ren van de handtekening gost3410.verify() is triviaal: we passeren de elliptische curve waarbinnen we werken (we leggen deze eenvoudigweg vast in ons GOSTIM-protocol), de publieke sleutel van de ondertekenaar (vergeet niet dat dit een tupel van twee moet zijn grote getallen, en geen bytereeks), 34.11-2012-XNUMX-hash en de handtekening zelf.
Vervolgens bereiden we in de initiator een handshakebericht voor en sturen dit naar handshake2, waarbij we dezelfde acties uitvoeren als tijdens de verificatie, alleen symmetrisch: onze sleutels ondertekenen in plaats van controleren, enz...
Wanneer de sessie tot stand is gebracht, worden transportsleutels gegenereerd (een afzonderlijke sleutel voor codering, voor authenticatie, voor elk van de partijen) en wordt de Grasshopper geΓ―nitialiseerd om de MAC te decoderen en te controleren:
De msg_sender coroutine codeert nu berichten voordat ze via een TCP-verbinding worden verzonden. Elk bericht heeft een monotoon toenemende nonce, die ook de initialisatievector is wanneer deze in de tellermodus wordt gecodeerd. Elk bericht en berichtblok heeft gegarandeerd een andere tellerwaarde.
GOSTIM is uitsluitend bedoeld voor educatieve doeleinden (aangezien het tenminste niet onder tests valt)! De broncode van het programma kan worden gedownload hier (Π‘ΡΡΠΈΠ±ΠΎΠ³-256 Ρ ΡΡ: 995bbd368c04e50a481d138c5fa2e43ec7c89bc77743ba8dbabee1fde45de120). ΠΠ°ΠΊ ΠΈ Π²ΡΠ΅ ΠΌΠΎΠΈ ΠΏΡΠΎΠ΅ΠΊΡΡ, ΡΠΈΠΏΠ° GoGOST, PyDERASN, NCCP, GoVPN, GOSTIM is volledig gratis software, verspreid onder de voorwaarden GPLv3 +.