Package: readonce
What is Read-Once object?This concept is defined and explained inSecure by Designbook.It is also exposed in this linkLiveBook.The overall characteristics of Read-Once objects, grabbed fromBook Review: Secure by DesignRead-once objects

A read-once object is an object designed to be read once (or a limited number of times). This object usually represents a value or concept in your domain that’s considered to be sensitive (for example, passport numbers, credit card numbers, or passwords). The main purpose of the read-once object is to facilitate detection of unintentional use of the data it encapsulates.

Here’s a list of the key aspects of a read-once object:

    Its main purpose is to facilitate detection of unintentional use.
    It represents a sensitive value or concept.
    It’s often a domain primitive.
    Its value can be read once, and once only.
    It prevents serialization of sensitive data.
    It prevents sub-classing and extension.About the UsageImagine that you need to pass a password it to some kind of service, which is going to Login your user.
The Login service will only require this password only once, so why not to restrict it to be read, used only Once?Important informationThe ReadOnce object is not a cryptography library.
It is not responsible for encrypting/decrypting or dictating any type of cryptography methods to store and retrieve your secrets.The main idea behind the ReadOnce objects is hardening the access and allow only one time access to the stored secrets in the object.Question: Are the secrets stored in plain text?Answer: No, we use symmetric encryption from cryptography namedFernet.
And we store passed secret data internally as so called fernet token(ciphertext+hash value).
When you try to retrieve the secret back, it is decrypted with the same key and send back as a plain text.Question: Then we have an encryption+hashing of plaintext?Answer: Yes, but ReadOnce class expects already encrypted(+ peppered, salted, hashed etc.) string to be passed as a secret,
because when you retrieve the secret it will return what it was originally passed.
If you passed the secret as "12345" you will get back "12345".
If you passed the secret something like "HmDVbz6N3MlXqZ4q3TvLakMQ9fQjI45yhuoRHyZWiM4=" you will get back the same string.Question: Then why you store the secret as a token(ciphertext+hash value)?Answer: It is an extra layer of hardening, if somehow, somebody could get the ReadOnce object and tries to steal the secret, lovely attacker should also have the key.
Okay the key also stored in ReadOnce object, but it is a non-trivial to get the key back.
If the attacker tries to add new secret over old one, the encryption key also updated.Install using pip:pip install readonceThen just inherit from theReadOnce:fromreadonceimportReadOnceclassPassword(ReadOnce):def__init__(self,password:str)->None:super().__init__()self.add_secret(password)Here the password string is added as a secret.
From our definition it can be read only once and only usingget_secret(), no direct access to the secret.You can not expose the object properties as well:>>>obj=Password(password="awesome_password")>>>dir(obj)[]>>>obj.__dict__{}Trying to read the password twice:>>>obj.get_secret()'awesome_password'>>>obj.get_secret()Traceback(mostrecentcalllast):File"<stdin>",line1,in<module>File"/home/shako/REPOS/py-read-once/.venv/lib/python3.10/site-packages/readonce.py",line47,inget_secretraiseUnsupportedOperationException("Sensitive data was already consumed")readonce.UnsupportedOperationException:('Not allowed on sensitive value','Sensitive data was already consumed')If someone tries to add its own secret to already instantiated object and then get back already defined secret data(original secret),
it will get only new secret.>>>obj=Password(password="awesome_password")>>>obj.add_secret("new_fake_date")>>>obj.get_secret()'new_fake_date'>>>obj.get_secret()Traceback(mostrecentcalllast):File"<stdin>",line1,in<module>File"/home/shako/REPOS/py-read-once/.venv/lib/python3.10/site-packages/readonce.py",line47,inget_secretraiseUnsupportedOperationException("Sensitive data was already consumed")readonce.UnsupportedOperationException:('Not allowed on sensitive value','Sensitive data was already consumed')You cannot create a subclass from sensitive class, it is a way of expose parent class data, but no success:>>>classFakePassword(Password):.........Traceback(mostrecentcalllast):File"<stdin>",line1,in<module>File"/home/shako/REPOS/py-read-once/.venv/lib/python3.10/site-packages/readonce.py",line21,in__new__raiseTypeError("Subclassing final classes is restricted")TypeError:SubclassingfinalclassesisrestrictedIf somebody tries to access secrets directly:>>>obj.secrets[]>>>obj._ReadOnce__secrets[]You can not pickle it:>>>importpickle>>>pickle.dumps(obj)Traceback(mostrecentcalllast):File"<stdin>",line1,in<module>File"/home/shako/REPOS/py-read-once/.venv/lib/python3.10/site-packages/readonce.py",line87,in__getstate__raiseUnsupportedOperationException()readonce.UnsupportedOperationException:NotallowedonsensitivevalueYou can not JSON serialize it:With default encoder:>>>importjson>>>json.dumps(obj)Traceback(mostrecentcalllast):File"<stdin>",line1,in<module>File"/usr/lib/python3.10/json/__init__.py",line231,indumpsreturn_default_encoder.encode(obj)File"/usr/lib/python3.10/json/encoder.py",line199,inencodechunks=self.iterencode(o,_one_shot=True)File"/usr/lib/python3.10/json/encoder.py",line257,initerencodereturn_iterencode(o,0)File"/usr/lib/python3.10/json/encoder.py",line179,indefaultraiseTypeError(f'Object of type{o.__class__.__name__}'TypeError:ObjectoftypePasswordisnotJSONserializableWith custom encoder:classCustomPasswordEncoder(json.JSONEncoder):defdefault(self,obj):try:return{"password":obj.get_secret()}exceptAttributeError:returnsuper().default(obj)>>>json.dumps(obj,cls=CustomPasswordEncoder)Traceback(mostrecentcalllast):File"<stdin>",line1,in<module>File"/usr/lib/python3.10/json/__init__.py",line238,indumps**kw).encode(obj)File"/usr/lib/python3.10/json/encoder.py",line199,inencodechunks=self.iterencode(o,_one_shot=True)File"/usr/lib/python3.10/json/encoder.py",line257,initerencodereturn_iterencode(o,0)File"<stdin>",line4,indefaultFile"/home/shako/REPOS/py-read-once/.venv/lib/python3.10/site-packages/readonce.py",line48,inget_secretraiseUnsupportedOperationException("Sensitive data can not be serialized")readonce.UnsupportedOperationException:('Not allowed on sensitive value','Sensitive data can not be serialized')At some points the class itself can be silently dumped to logs, but not here:>>>obj=Password(password="awesome_password")>>>print(obj)ReadOnce[secrets=*****]>>>objReadOnce[secrets=*****]How about PythonDataclasses?Regarding dataclasses, it is prohibited to directly define field then add it to secret:fromreadonceimportReadOncefromdataclassesimportdataclass@dataclassclassDBPassword(ReadOnce):password:strdef__post_init__(self):# This is going to fail with "AttributeError: 'DBPassword' object has no attribute 'password'"self.add_secret(self.password)The result will be:DBPassword(password="awesome")...AttributeError:'DBPassword'objecthasnoattribute'password'The better way either to use fields as a "descriptor" way.
Imagine you have an idea to share your database credentials in whole chunk.
We can create separate sensitive data holders or secrets for each information:fromreadonceimportReadOnceclassPassword(ReadOnce):def__init__(self,password:str)->None:super().__init__()self.add_secret(password)classDBUri(ReadOnce):def__init__(self,uri:str)->None:super().__init__()self.add_secret(uri)classDBPort(ReadOnce):def__init__(self,port:int)->None:super().__init__()self.add_secret(port)classDBHost(ReadOnce):def__init__(self,host:str)->None:super().__init__()self.add_secret(host)Then we can combine them in one dataclass:@dataclassclassDBCredentialsWithDescriptors:password:Password=Password("db-password")uri:DBUri=DBUri("mysql://")port:DBPort=DBPort(3306)host:DBHost=DBHost("localhost")In this way, further we can get our secrets back, again usingget_secret()and only once:>>>credentials=DBCredentialsWithDescriptors()>>>credentials.password.get_secret()'db-password'>>>credentials.password.get_secret()...readonce.UnsupportedOperationException:('Not allowed on sensitive value','Sensitive data was already consumed')Printing or dumping credentials object will not give any valuable information as well:>>>print(credentials)DBCredentialsWithDescriptors(password=ReadOnce[secrets=*****],uri=ReadOnce[secrets=*****],port=ReadOnce[secrets=*****],host=ReadOnce[secrets=*****])Okay, this not a full "descriptors" in terms of Python(no__get__and__set__), but I did not open this door intentionally.Another way of using dataclasses is just declaring the fields:@dataclassclassDBCredentials:password:Passworduri:DBUriport:DBPorthost:DBHostThen initialize the fields in the future. This approach is similar to DTOs(data transfer objects).Is it possible to JSON serializeDBCredentials? Impossible if you decided to dump sensitive fields:
Trying with custom encoder:importjsonclassCustomDBCredentialsEncoder(json.JSONEncoder):defdefault(self,obj):try:# Intentionally omit other fieldsreturn{"uri":obj.uri.get_secret()}exceptAttributeError:returnsuper().default(obj)>>>credentials=DBCredentialsWithDescriptors()>>>json.dumps(credentials,cls=CustomDBCredentialsEncoder)...readonce.UnsupportedOperationException:('Not allowed on sensitive value','Sensitive data can not be serialized')Same applies to pickling:>>>importpickle>>>pickle.dumps(credentials)...readonce.UnsupportedOperationException:NotallowedonsensitivevalueRelation withPydanticAs we know the Pydantic models is a de-facto standard for data validation based on type annotations, we can easily use ReadOnce objects with Pydantic.
In this section I am going to share some tests.The simplest way to declare Pydantic models with ReadOnce objects is to allow arbitrary types:frompydanticimportBaseModelclassDBCredentialsModel(BaseModel):comment:strpassword:Passworduri:DBUriport:DBPorthost:DBHostclassConfig:arbitrary_types_allowed=TrueCreating credentials:>>>credentials=DBCredentialsModel(comment="The Hacked Database",password=Password("db-password"),uri=DBUri("mysql://"),port=DBPort(3306),host=DBHost("localhost"))>>>credentialsDBCredentialsModel(comment='The Hacked Database',password=ReadOnce[secrets=*****],uri=ReadOnce[secrets=*****],port=ReadOnce[secrets=*****],host=ReadOnce[secrets=*****])Again the sensitive data is not exposed:credentials.dict(){'comment':'The Hacked Database','password':ReadOnce[secrets=*****],'uri':ReadOnce[secrets=*****],'port':ReadOnce[secrets=*****],'host':ReadOnce[secrets=*****]}It can not be serialized in a default way:>>>credentials.json()...TypeError:Objectoftype'Password'isnotJSONserializableUnfortunately, the nature of the ReadOnce object prevents using powerful validation mechanics in the model class.
In its core, the sensitive object can not be used twice if it was already consumed:You can call arbitrary timeadd_secret()if noget_secret()was called before it.Whenever you calledget_secret()the sensitive object is considered as exhausted.Imagine we want to validate the password length and try to add custom validator inside the Pydantic model:frompydanticimportBaseModel,validatorclassInvalidDBCredentialsModel(BaseModel):comment:strpassword:Passworduri:DBUriport:DBPorthost:DBHost@validator("password")defpassword_length_check(cls,v):passwd=v.get_secret()iflen(passwd)>7:v.add_secret(passwd)returnvraiseValueError("Password length should be more than 7")classConfig:arbitrary_types_allowed=TrueAs you can expect, we need first get the secret data then validate it, if validation is okay we need to put that secret back to the sensitive object, which is not possible.Therefore, it is better to push the validation logic towardsPasswordsensitive class instead. We will explore the validation in-depth in the future.If we test thisInvalidDBCredentialsModelit should fail with:readonce.UnsupportedOperationException: ('Not allowed on sensitive value', 'Sensitive object exhausted; you can not use it twice')If you have any further Pydantic ideas please open an issue, we can explore and figure out the best usageApplying best practices from Design by ContractIn order to further ensure data(secret) integrity and security,
we can useDbCideas as it gives us cleaner way of defining reusable constraints.I likeicontractpackage which is quite handy tool.
I have tried to explain this YouTube tutorial as wellDesign-by-Contract programming with Python.Let's redefine our sensitive class as:importicontractfromreadonceimportReadOncedefvalidate_password_length(password:str)->bool:returnlen(password)>7classPassword(ReadOnce):@icontract.ensure(lambdaself:len(self)==1,"Secret is missing")@icontract.require(lambdapassword:validate_password_length(password),"Password length should be more than 7",)def__init__(self,password:str)->None:super().__init__()self.add_secret(password)The current password validation is quite naive, it just checks the length of the string: this is ourpre-conditionand
it is marked as@icontract.require.But what is@icontract.ensurethen?
This is our so called,post-condition: after adding secret the length of the secrets storage must be equal to one.We can add more sophisticated password validation using regex, it is up to your business needs.
The question should be asked here:"What is a password for our application?"importredefvalidate_password(password:str)->bool:reg=r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!#%*?&]{6,20}$"pattern=re.compile(reg)returnbool(re.search(pattern,password))After writing down password requirements you can convert them topre-conditionsas part of your DbC approach.Is it possible to monkeypatch the secret logic management?As far, as I have tested it,
it is really non-trivial and over-hardened to monkeypatch the secret storage and also the secret management methods.For eg, following test clearly shows the consequences:deftest_monkeypatch_change_secrets_storage(get_password_obj,monkeypatch):# Directly trying to monkeypatch will raise double UnsupportedOperationException;# As monkeypatch in its default undo() section tries to again edit "_ReadOnce__secrets" which raises same exception# That's why we use context() below# with pytest.raises(UnsupportedOperationException):#     monkeypatch.setattr(get_password_obj, "_ReadOnce__secrets", ["12345"], raising=False)withpytest.raises(UnsupportedOperationException):withmonkeypatch.context()asm:m.setattr(get_password_obj,"_ReadOnce__secrets",["12345"],raising=True)How to install for development?Create and activate the virtualenv:python3.10 -m venv .venvsource .venv/bin/activateWe use flit for the package management:Install flit:pip install flit==3.7.1Installing project for developmentmake install-devorflit install --env --deps=develop --symlinkInstalling for general showcasemake installorflit install --env --deps=developRun all tests in verbosemake testorpytest -svv