Source code for cadrumo.adapters.persistence.storage.recovery_key
"""Recovery-code minting and its canonical grouped-code encoder.A recovery code is an opaque high-entropy secret as far as every consumeris concerned; this module is the only thing in the substrate that can mintone strong enough to resist offline guessing. A profile's recovery envelopewraps that profile's DEK under its own supervised Argon2id parameters andtakes the canonical code as its secret. The encoder is bound to no custodyarchitecture, no file layout and no key schedule, and the substrate neverpersists the code.**Shape.** Six groups of five symbols drawn from a 32-symbol alphabet thatomits the characters people misread (``0/O``, ``1/I/L``), joined withhyphens: ``XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX``. Thirty symbols at five bitseach carry 150 bits of entropy, which is more than the 128 bits behind thepassword envelope's own key. Operator input is normalised before proof:case, hyphens and whitespace are cosmetic, so a code typed in lowercasewithout separators proves possession exactly as the displayed form does.**Wipeable key material.** The code is held in a ``bytearray`` rather than animmutable ``str``, so the substrate's :func:`zeroise` primitive can overwriteit in place once the operator has copied it down. The honest limit is theone the session path already discloses: reading :attr:`RecoveryKey.code`materialises a transient ``str`` copy whose lifetime the garbage collectorowns. What this module does not do is hold the *only* copy of a secret in aform no wipe primitive can reach."""from__future__importannotationsimportsecretsfromtypingimportFinal,Selffrom....core.external_constantsimportUTF_8_ENCODINGas_UTF_8_ENCODINGfrom.custody.zeroiseimportzeroiseas_zeroisefrom.errorsimportstorage_validation_erroras_storage_validation_errorRECOVERY_CODE_ALPHABET:Final[str]="ABCDEFGHJKLMNPQRSTUVWXYZ23456789"RECOVERY_CODE_GROUP_LENGTH:Final[int]=5RECOVERY_CODE_GROUP_COUNT:Final[int]=6RECOVERY_CODE_SEPARATOR:Final[str]="-"RECOVERY_CODE_SYMBOL_COUNT:Final[int]=RECOVERY_CODE_GROUP_LENGTH*RECOVERY_CODE_GROUP_COUNT_ALPHABET_SET:Final[frozenset[str]]=frozenset(RECOVERY_CODE_ALPHABET)_IGNORED_INPUT_CHARACTERS:Final[frozenset[str]]=frozenset({RECOVERY_CODE_SEPARATOR," ","\t","\n","\r","_"})
[docs]classRecoveryKey:"""Wipeable container for one canonical recovery code. Deliberately not a pydantic model. The sibling ``BucketSession`` sets the precedent for live key material: a slotted plain class keeps the buffer mutable, and -- because there is no ``model_dump_json`` -- makes it structurally impossible to serialise the secret by accident. """__slots__=("_code_buffer",)def__init__(self,*,code:str)->None:"""Copy ``code`` into the wipeable buffer this key owns. Args: code: A canonical recovery code, exactly as :func:`format_recovery_code` renders it. Raises: StorageValidationError: If the code is not in canonical form. """ifcode!=format_recovery_code(normalise_recovery_code(code)):raise_storage_validation_error("recovery code must be in canonical grouped form")self._code_buffer=bytearray(code.encode(_UTF_8_ENCODING))@propertydefcode(self)->str:"""Return the canonical grouped code, decoded from its wipeable buffer."""returnself._code_buffer.decode(_UTF_8_ENCODING)
[docs]defwipe(self)->None:"""Overwrite the code buffer with zero bytes. Idempotent: wiping an already-wiped key is a no-op that leaves the buffer zeroed and its length unchanged. """_zeroise(self._code_buffer)
def__enter__(self)->Self:"""Return this key, so a ``with`` block bounds the secret's lifetime."""returnselfdef__exit__(self,*_exc_info:object)->None:"""Wipe the buffer on block exit, whether or not the body raised."""self.wipe()
[docs]defnormalise_recovery_code(text:str)->str:"""Reduce operator input to the bare symbol run the code was minted from. Case, hyphens, underscores and whitespace are cosmetic and dropped. Any other character, or a run that is not exactly the minted length, is refused: a code that cannot be the minted one must not reach the KDF. Raises: StorageValidationError: When ``text`` cannot be a recovery code. """symbols="".join(characterforcharacterintext.upper()ifcharacternotin_IGNORED_INPUT_CHARACTERS)iflen(symbols)!=RECOVERY_CODE_SYMBOL_COUNT:raise_storage_validation_error(f"recovery code must contain exactly {RECOVERY_CODE_SYMBOL_COUNT} symbols; got {len(symbols)}",)ifany(characternotin_ALPHABET_SETforcharacterinsymbols):raise_storage_validation_error("recovery code contains a character outside its alphabet")returnsymbols
[docs]defformat_recovery_code(symbols:str)->str:"""Render a bare symbol run in the canonical hyphen-grouped form."""iflen(symbols)!=RECOVERY_CODE_SYMBOL_COUNTorany(characternotin_ALPHABET_SETforcharacterinsymbols):raise_storage_validation_error("recovery code symbols are not a complete alphabet run")groups=(symbols[index:index+RECOVERY_CODE_GROUP_LENGTH]forindexinrange(0,RECOVERY_CODE_SYMBOL_COUNT,RECOVERY_CODE_GROUP_LENGTH))returnRECOVERY_CODE_SEPARATOR.join(groups)
[docs]defcanonical_recovery_code(text:str)->str:"""Return the canonical form of an operator-supplied code, or refuse it."""returnformat_recovery_code(normalise_recovery_code(text))
[docs]defgenerate_recovery_key()->RecoveryKey:"""Mint a fresh :class:`RecoveryKey` from the process CSPRNG. Each symbol is drawn independently with :func:`secrets.choice`, so the code carries the full five bits per symbol. The returned record is the only in-memory copy; callers must arrange for the operator to copy the code down, then :meth:`RecoveryKey.wipe` it. """symbols="".join(secrets.choice(RECOVERY_CODE_ALPHABET)for_inrange(RECOVERY_CODE_SYMBOL_COUNT))returnRecoveryKey(code=format_recovery_code(symbols))