master now emit audio in the network and others machines play it at the given time.
This commit is contained in:
parent
142d0f6db8
commit
077d1d3d9d
15 changed files with 302 additions and 30 deletions
|
@ -1,4 +1,16 @@
|
||||||
sounddevice
|
# extended standard
|
||||||
msgpack
|
|
||||||
cryptography
|
|
||||||
bidict
|
bidict
|
||||||
|
pause
|
||||||
|
sortedcontainers
|
||||||
|
numpy
|
||||||
|
|
||||||
|
# networking
|
||||||
|
msgpack
|
||||||
|
|
||||||
|
# cryptography
|
||||||
|
cryptography
|
||||||
|
|
||||||
|
# audio
|
||||||
|
pydub
|
||||||
|
audioop-lts
|
||||||
|
sounddevice
|
12
source/behaviors/events/AudioEvent.py
Normal file
12
source/behaviors/events/AudioEvent.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
from source import packets
|
||||||
|
from source.behaviors.events import base
|
||||||
|
|
||||||
|
|
||||||
|
class AudioEvent(base.BaseEvent):
|
||||||
|
"""
|
||||||
|
Event reacting to receiving audio data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def handle(self, packet: packets.AudioPacket, address: tuple):
|
||||||
|
# add the audio chunk to the buffer of audio packet to play
|
||||||
|
self.manager.audio.add_audio(packet)
|
|
@ -2,3 +2,4 @@ from . import base
|
||||||
|
|
||||||
from .DiscoveryEvent import DiscoveryEvent
|
from .DiscoveryEvent import DiscoveryEvent
|
||||||
from .PeerEvent import PeerEvent
|
from .PeerEvent import PeerEvent
|
||||||
|
from .AudioEvent import AudioEvent
|
|
@ -1,6 +1,13 @@
|
||||||
import time
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pause
|
||||||
|
import pydub
|
||||||
|
|
||||||
from source.behaviors.roles import base
|
from source.behaviors.roles import base
|
||||||
|
from source.managers import Manager
|
||||||
|
from source.packets import AudioPacket
|
||||||
|
from source.utils.crypto.type import CipherType
|
||||||
|
|
||||||
|
|
||||||
class MasterRole(base.BaseRole):
|
class MasterRole(base.BaseRole):
|
||||||
|
@ -9,7 +16,47 @@ class MasterRole(base.BaseRole):
|
||||||
It will be the machine responsible for emitting data that the others peers should play together.
|
It will be the machine responsible for emitting data that the others peers should play together.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def handle(self):
|
TARGET_SIZE: int = 60 * 1024 # set an upper bound because of the IPv6 maximum packet size.
|
||||||
print("I am now the master !")
|
|
||||||
|
|
||||||
time.sleep(1)
|
def __init__(self, manager: "Manager"):
|
||||||
|
super().__init__(manager)
|
||||||
|
|
||||||
|
# generate a random secret key for AES communication
|
||||||
|
self.manager.communication.secret_key = os.urandom(32)
|
||||||
|
|
||||||
|
# prepare the audio file that will be streamed
|
||||||
|
self.audio = pydub.AudioSegment.from_file("../assets/Caravan Palace - Wonderland.mp3")
|
||||||
|
self.play_time = datetime.now()
|
||||||
|
|
||||||
|
|
||||||
|
def handle(self):
|
||||||
|
# TODO(Faraphel): check if another server is emitting sound in the network. Return to undefined if yes
|
||||||
|
|
||||||
|
# calculate the number of bytes per milliseconds in the audio
|
||||||
|
bytes_per_ms = self.audio.frame_rate * self.audio.sample_width * self.audio.channels / 1000
|
||||||
|
# calculate the required chunk duration to reach that size
|
||||||
|
chunk_duration = timedelta(milliseconds=self.TARGET_SIZE / bytes_per_ms)
|
||||||
|
|
||||||
|
# calculate the audio time
|
||||||
|
chunk_start_time = datetime.now() - self.play_time
|
||||||
|
chunk_end_time = chunk_start_time + chunk_duration
|
||||||
|
|
||||||
|
# get the music for that period
|
||||||
|
chunk = self.audio[
|
||||||
|
chunk_start_time.total_seconds() * 1000 :
|
||||||
|
chunk_end_time.total_seconds() * 1000
|
||||||
|
]
|
||||||
|
|
||||||
|
# broadcast it in the network
|
||||||
|
audio_packet = AudioPacket(
|
||||||
|
datetime.now() + timedelta(seconds=5), # play it in some seconds to let all the machines get the sample
|
||||||
|
chunk.channels,
|
||||||
|
chunk.frame_rate,
|
||||||
|
chunk.sample_width,
|
||||||
|
chunk.raw_data,
|
||||||
|
)
|
||||||
|
self.manager.communication.broadcast(audio_packet, CipherType.AES_CBC)
|
||||||
|
|
||||||
|
# wait for the audio to play
|
||||||
|
# TODO(Faraphel): should adapt to the compute time above
|
||||||
|
pause.until(self.play_time + chunk_end_time)
|
||||||
|
|
11
source/behaviors/roles/SlaveRole.py
Normal file
11
source/behaviors/roles/SlaveRole.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
from source.behaviors.roles import base
|
||||||
|
|
||||||
|
class SlaveRole(base.BaseRole):
|
||||||
|
"""
|
||||||
|
Role used when the machine is declared as a slave.
|
||||||
|
It shall listen for a master and check if everything is working properly
|
||||||
|
"""
|
||||||
|
|
||||||
|
def handle(self):
|
||||||
|
# TODO(Faraphel): ping the server and check if it is working properly. Return to undefined if no.
|
||||||
|
pass
|
68
source/managers/AudioManager.py
Normal file
68
source/managers/AudioManager.py
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import numpy
|
||||||
|
import pause
|
||||||
|
import sortedcontainers
|
||||||
|
import sounddevice
|
||||||
|
|
||||||
|
from source import packets, managers
|
||||||
|
from source.utils.audio.audio import sample_width_to_type
|
||||||
|
|
||||||
|
|
||||||
|
class AudioManager:
|
||||||
|
def __init__(self, manager: "managers.Manager"):
|
||||||
|
# buffer containing the set of audio chunk to play. Sort them by their time to play
|
||||||
|
self.buffer = sortedcontainers.SortedList(key=lambda audio: audio.time)
|
||||||
|
|
||||||
|
# thread support
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.new_audio_event = threading.Event() # event triggered when a new audio have been added
|
||||||
|
|
||||||
|
def add_audio(self, audio: packets.AudioPacket) -> None:
|
||||||
|
"""
|
||||||
|
Add a new audio chunk to play
|
||||||
|
:param audio: the audio chunk to play
|
||||||
|
"""
|
||||||
|
|
||||||
|
with self.lock:
|
||||||
|
# add the audio packet to the buffer
|
||||||
|
self.buffer.add(audio)
|
||||||
|
# trigger the new audio event
|
||||||
|
self.new_audio_event.set()
|
||||||
|
|
||||||
|
def handle(self) -> None:
|
||||||
|
"""
|
||||||
|
Play the audio chunk in the buffer at the given time
|
||||||
|
"""
|
||||||
|
|
||||||
|
# wait for a new audio packet
|
||||||
|
self.new_audio_event.wait()
|
||||||
|
|
||||||
|
# get the most recent audio packet to play
|
||||||
|
audio: packets.AudioPacket = self.buffer.pop(0)
|
||||||
|
|
||||||
|
# if the audio should have been played before, skip it
|
||||||
|
if audio.time < datetime.now():
|
||||||
|
return
|
||||||
|
|
||||||
|
# create a numpy array for our sample
|
||||||
|
sample = numpy.frombuffer(audio.data, dtype=sample_width_to_type(numpy.int16))
|
||||||
|
# reshape it to have a sub-array for each channels
|
||||||
|
sample = sample.reshape((-1, audio.channels))
|
||||||
|
# normalize the sample to be between -1 and 1
|
||||||
|
sample = sample / (2 ** (audio.sample_width * 8 - 1))
|
||||||
|
|
||||||
|
# wait for the audio given time
|
||||||
|
pause.until(audio.time)
|
||||||
|
|
||||||
|
# play the audio
|
||||||
|
sounddevice.play(sample, audio.sample_rate)
|
||||||
|
|
||||||
|
def loop(self) -> None:
|
||||||
|
"""
|
||||||
|
Handle forever
|
||||||
|
"""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
self.handle()
|
|
@ -37,7 +37,7 @@ class CommunicationManager:
|
||||||
self.private_key, self.public_key = rsa_create_key_pair()
|
self.private_key, self.public_key = rsa_create_key_pair()
|
||||||
|
|
||||||
# TODO(Faraphel): should be decided by the server when changing role, stored somewhere else
|
# TODO(Faraphel): should be decided by the server when changing role, stored somewhere else
|
||||||
self.secret_key: bytes = b"secret key!"
|
self.secret_key: bytes = b"secret key!".zfill(32)
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
# close the socket
|
# close the socket
|
||||||
|
@ -82,12 +82,15 @@ class CommunicationManager:
|
||||||
case CipherType.PLAIN:
|
case CipherType.PLAIN:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
case CipherType.RSA:
|
|
||||||
packet_data = utils.crypto.rsa.rsa_encrypt(packet_data, self.public_key)
|
|
||||||
|
|
||||||
case CipherType.AES_ECB:
|
case CipherType.AES_ECB:
|
||||||
packet_data = utils.crypto.aes.aes_ecb_encrypt(packet_data, self.secret_key)
|
packet_data = utils.crypto.aes.aes_ecb_encrypt(packet_data, self.secret_key)
|
||||||
|
|
||||||
|
case CipherType.AES_CBC:
|
||||||
|
packet_data = utils.crypto.aes.aes_cbc_encrypt(packet_data, self.secret_key)
|
||||||
|
|
||||||
|
case CipherType.RSA:
|
||||||
|
packet_data = utils.crypto.rsa.rsa_encrypt(packet_data, self.public_key)
|
||||||
|
|
||||||
case _:
|
case _:
|
||||||
raise ValueError(f"Unknown cipher: {cipher_type}")
|
raise ValueError(f"Unknown cipher: {cipher_type}")
|
||||||
|
|
||||||
|
@ -112,12 +115,15 @@ class CommunicationManager:
|
||||||
case CipherType.PLAIN:
|
case CipherType.PLAIN:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
case CipherType.RSA:
|
|
||||||
packet_data = utils.crypto.rsa.rsa_decrypt(packet_data, self.private_key)
|
|
||||||
|
|
||||||
case CipherType.AES_ECB:
|
case CipherType.AES_ECB:
|
||||||
packet_data = utils.crypto.aes.aes_ecb_decrypt(packet_data, self.secret_key)
|
packet_data = utils.crypto.aes.aes_ecb_decrypt(packet_data, self.secret_key)
|
||||||
|
|
||||||
|
case CipherType.AES_CBC:
|
||||||
|
packet_data = utils.crypto.aes.aes_cbc_decrypt(packet_data, self.secret_key)
|
||||||
|
|
||||||
|
case CipherType.RSA:
|
||||||
|
packet_data = utils.crypto.rsa.rsa_decrypt(packet_data, self.private_key)
|
||||||
|
|
||||||
case _:
|
case _:
|
||||||
raise ValueError(f"Unknown cipher: {cipher_type}")
|
raise ValueError(f"Unknown cipher: {cipher_type}")
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import traceback
|
||||||
import typing
|
import typing
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
|
@ -56,7 +57,7 @@ class EventManager:
|
||||||
self.manager.event.handle(packet, address)
|
self.manager.event.handle(packet, address)
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("Stopping listener.")
|
break
|
||||||
|
|
||||||
except Exception as exception:
|
except:
|
||||||
warnings.warn(str(exception))
|
warnings.warn(traceback.format_exc())
|
||||||
|
|
|
@ -10,21 +10,26 @@ class Manager:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, interface: str):
|
def __init__(self, interface: str):
|
||||||
from . import CommunicationManager, EventManager, RoleManager
|
from . import CommunicationManager, EventManager, RoleManager, AudioManager
|
||||||
|
|
||||||
# communication manager
|
# communication manager
|
||||||
self.communication = CommunicationManager(self, interface)
|
self.communication = CommunicationManager(self, interface)
|
||||||
self.communication.register_packet_type(b"DISC", packets.DiscoveryPacket)
|
self.communication.register_packet_type(b"DISC", packets.DiscoveryPacket)
|
||||||
self.communication.register_packet_type(b"PEER", packets.PeerPacket)
|
self.communication.register_packet_type(b"PEER", packets.PeerPacket)
|
||||||
|
self.communication.register_packet_type(b"AUDI", packets.AudioPacket)
|
||||||
|
|
||||||
# event manager
|
# event manager
|
||||||
self.event = EventManager(self)
|
self.event = EventManager(self)
|
||||||
self.event.register_event_handler(packets.DiscoveryPacket, events.DiscoveryEvent(self))
|
self.event.register_event_handler(packets.DiscoveryPacket, events.DiscoveryEvent(self))
|
||||||
self.event.register_event_handler(packets.PeerPacket, events.PeerEvent(self))
|
self.event.register_event_handler(packets.PeerPacket, events.PeerEvent(self))
|
||||||
|
self.event.register_event_handler(packets.AudioPacket, events.AudioEvent(self))
|
||||||
|
|
||||||
# role manager
|
# role manager
|
||||||
self.role = RoleManager(self)
|
self.role = RoleManager(self)
|
||||||
|
|
||||||
|
# audio manager
|
||||||
|
self.audio = AudioManager(self)
|
||||||
|
|
||||||
# set of addresses associated to their peer
|
# set of addresses associated to their peer
|
||||||
self.peers: dict[tuple, packets.PeerPacket] = {}
|
self.peers: dict[tuple, packets.PeerPacket] = {}
|
||||||
|
|
||||||
|
@ -36,9 +41,12 @@ class Manager:
|
||||||
# run a thread for the event and the role manager
|
# run a thread for the event and the role manager
|
||||||
event_thread = threading.Thread(target=self.event.loop)
|
event_thread = threading.Thread(target=self.event.loop)
|
||||||
role_thread = threading.Thread(target=self.role.loop)
|
role_thread = threading.Thread(target=self.role.loop)
|
||||||
|
audio_thread = threading.Thread(target=self.audio.loop)
|
||||||
|
|
||||||
event_thread.start()
|
event_thread.start()
|
||||||
role_thread.start()
|
role_thread.start()
|
||||||
|
audio_thread.start()
|
||||||
|
|
||||||
event_thread.join()
|
event_thread.join()
|
||||||
role_thread.join()
|
role_thread.join()
|
||||||
|
audio_thread.join()
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
from .CommunicationManager import CommunicationManager
|
from .CommunicationManager import CommunicationManager
|
||||||
from .EventManager import EventManager
|
from .EventManager import EventManager
|
||||||
from .RoleManager import RoleManager
|
from .RoleManager import RoleManager
|
||||||
|
from .AudioManager import AudioManager
|
||||||
|
|
||||||
from .Manager import Manager
|
from .Manager import Manager
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
import zlib
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import msgpack
|
import msgpack
|
||||||
|
|
||||||
|
@ -11,20 +13,50 @@ class AudioPacket(base.BasePacket):
|
||||||
Represent a packet of audio data
|
Represent a packet of audio data
|
||||||
"""
|
"""
|
||||||
|
|
||||||
data: bytes = dataclasses.field()
|
# expected time to play the audio
|
||||||
|
time: datetime = dataclasses.field()
|
||||||
|
|
||||||
rate: int = dataclasses.field()
|
# audio details
|
||||||
channels: int = dataclasses.field()
|
channels: int = dataclasses.field()
|
||||||
encoding: int = dataclasses.field()
|
sample_rate: int = dataclasses.field()
|
||||||
|
sample_width: int = dataclasses.field()
|
||||||
|
|
||||||
|
# raw audio data
|
||||||
|
_data: bytes = dataclasses.field(repr=False)
|
||||||
|
# is the audio zlib compressed
|
||||||
|
compressed: bool = dataclasses.field(default=False)
|
||||||
|
|
||||||
|
|
||||||
def pack(self) -> bytes:
|
def pack(self) -> bytes:
|
||||||
return msgpack.packb((
|
return msgpack.packb((
|
||||||
self.data,
|
self.time.timestamp(),
|
||||||
self.rate,
|
|
||||||
self.channels,
|
self.channels,
|
||||||
self.encoding
|
self.sample_rate,
|
||||||
|
self.sample_width,
|
||||||
|
self._data,
|
||||||
|
self.compressed
|
||||||
))
|
))
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
# if the audio is not compressed, compress it
|
||||||
|
if not self.compressed:
|
||||||
|
self._data = zlib.compress(self._data)
|
||||||
|
self.compressed = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data(self):
|
||||||
|
return zlib.decompress(self._data) if self.compressed else self._data
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def unpack(cls, data: bytes):
|
def unpack(cls, data: bytes):
|
||||||
return cls(*msgpack.unpackb(data))
|
# unpack the message
|
||||||
|
timestamp, channels, sample_rate, sample_width, data, compressed = msgpack.unpackb(data)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
datetime.fromtimestamp(timestamp),
|
||||||
|
channels,
|
||||||
|
sample_rate,
|
||||||
|
sample_width,
|
||||||
|
data,
|
||||||
|
compressed,
|
||||||
|
)
|
||||||
|
|
0
source/utils/audio/__init__.py
Normal file
0
source/utils/audio/__init__.py
Normal file
19
source/utils/audio/audio.py
Normal file
19
source/utils/audio/audio.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import numpy
|
||||||
|
|
||||||
|
|
||||||
|
def sample_width_to_type(sample_width: int):
|
||||||
|
"""
|
||||||
|
Return the numpy type to use depending on the sample width used in an audio sample
|
||||||
|
:param sample_width: the sample width
|
||||||
|
:return: the corresponding numpy type
|
||||||
|
"""
|
||||||
|
|
||||||
|
match sample_width:
|
||||||
|
case 1:
|
||||||
|
return numpy.int8
|
||||||
|
case 2:
|
||||||
|
return numpy.int16
|
||||||
|
case 4:
|
||||||
|
return numpy.int32
|
||||||
|
case _:
|
||||||
|
return numpy.int16
|
|
@ -1,3 +1,5 @@
|
||||||
|
import os
|
||||||
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
from cryptography.hazmat.backends import default_backend
|
||||||
from cryptography.hazmat.primitives import padding
|
from cryptography.hazmat.primitives import padding
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
@ -24,7 +26,6 @@ def aes_ecb_encrypt(data: bytes, key: bytes) -> bytes:
|
||||||
|
|
||||||
return encrypted_data
|
return encrypted_data
|
||||||
|
|
||||||
|
|
||||||
def aes_ecb_decrypt(encrypted_data: bytes, key: bytes) -> bytes:
|
def aes_ecb_decrypt(encrypted_data: bytes, key: bytes) -> bytes:
|
||||||
"""
|
"""
|
||||||
Decrypt data encrypted with AES in CBC mode.
|
Decrypt data encrypted with AES in CBC mode.
|
||||||
|
@ -45,3 +46,54 @@ def aes_ecb_decrypt(encrypted_data: bytes, key: bytes) -> bytes:
|
||||||
data = unpadder.update(decrypted_data) + unpadder.finalize()
|
data = unpadder.update(decrypted_data) + unpadder.finalize()
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
def aes_cbc_encrypt(data: bytes, key: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
Encrypt the message using AES in CBC mode.
|
||||||
|
:param data: the data to cipher
|
||||||
|
:param key: the key to use for the cipher
|
||||||
|
:return: the encrypted data
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pad the data with PKCS7 for AES to work properly
|
||||||
|
padder = padding.PKCS7(128).padder()
|
||||||
|
padded_data = padder.update(data) + padder.finalize()
|
||||||
|
|
||||||
|
# create an initialisation vector
|
||||||
|
iv = os.urandom(16)
|
||||||
|
|
||||||
|
# create the AES cipher in CBC mode
|
||||||
|
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
|
||||||
|
encryptor = cipher.encryptor()
|
||||||
|
|
||||||
|
# encrypt the padded data
|
||||||
|
encrypted_data = encryptor.update(padded_data) + encryptor.finalize()
|
||||||
|
|
||||||
|
# prepend the iv to the encrypted data
|
||||||
|
return iv + encrypted_data
|
||||||
|
|
||||||
|
|
||||||
|
def aes_cbc_decrypt(payload: bytes, key: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
Decrypt data encrypted with AES in CBC mode.
|
||||||
|
:param payload: the encrypted data
|
||||||
|
:param key: the key used to encrypt it
|
||||||
|
:return: the decrypted data
|
||||||
|
"""
|
||||||
|
|
||||||
|
# split the payload into the iv and the encrypted data
|
||||||
|
iv = payload[:16]
|
||||||
|
encrypted_data = payload[16:]
|
||||||
|
|
||||||
|
# create the AES cipher in CBC mode
|
||||||
|
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
|
||||||
|
decryptor = cipher.decryptor()
|
||||||
|
|
||||||
|
# decrypt the encrypted data
|
||||||
|
decrypted_data = decryptor.update(encrypted_data) + decryptor.finalize()
|
||||||
|
|
||||||
|
# unpad the data
|
||||||
|
unpadder = padding.PKCS7(128).unpadder()
|
||||||
|
data = unpadder.update(decrypted_data) + unpadder.finalize()
|
||||||
|
|
||||||
|
return data
|
|
@ -4,14 +4,16 @@ import typing
|
||||||
|
|
||||||
class CipherType(enum.Enum):
|
class CipherType(enum.Enum):
|
||||||
PLAIN = 0x00
|
PLAIN = 0x00
|
||||||
AES_ECB = 0x01
|
AES_ECB = 0x01 # legacy
|
||||||
RSA = 0x02
|
AES_CBC = 0x02
|
||||||
|
RSA = 0x10
|
||||||
|
|
||||||
|
|
||||||
CIPHER_SYMMETRIC_TYPES: typing.Final[list[CipherType]] = [
|
CIPHER_SYMMETRIC_TYPES: typing.Final[list[CipherType]] = [
|
||||||
CipherType.PLAIN,
|
CipherType.PLAIN,
|
||||||
CipherType.AES_ECB
|
CipherType.AES_ECB,
|
||||||
|
CipherType.AES_CBC,
|
||||||
]
|
]
|
||||||
CIPHER_ASYMMETRIC_TYPES: typing.Final[list[CipherType]] = [
|
CIPHER_ASYMMETRIC_TYPES: typing.Final[list[CipherType]] = [
|
||||||
CipherType.RSA
|
CipherType.RSA,
|
||||||
]
|
]
|
Loading…
Reference in a new issue