G

plugin XEP-0420: Implementation of Stanza Content Encryption:

<div xmlns="http://www.w3.org/1999/xhtml"><p>Includes implementation of XEP-0082 (XMPP date and time profiles) and tests for both new plugins.
Everything is type checked, linted, format checked and unit tested.
Adds new dependency xmlschema.

fix 377</p></div>

G

goffi 24/08/2022, 08:19

Hi,

first, thank you for your work.

A few remarks:

1) aren't pylint and mypy overlapping? Note that I'm planning in a relatively short term to move to Poetry and pyproject.toml, thus it would be good to avoid adding too many configuration files which will have to be ported. I'm also not a big fan of the # pylint: … comments everywhere, but I can live with it in some plugins for now.

2) please use the same docstring convention as the rest of the code:

Short description

More information if needed
@param param_name: doc
@return: doc
@raise exception: reason

3) why did you alias _ as i18n_untyped and D_ as deferred_i18n_untyped? The short name were choose on purpose as it makes the code more readable, and _ is the usual convention for gettext. Please keep original names.

4) regarding method name, Libervia use camelCase for method name due to the historic use of Twisted which does the same (PEP8 even says that we can keep original style in this case). However, I'm willing to move to snake_case (and some methods are already in snake_case). I'm not sure if the time is right to do that and if mixing both is a good idea right thing. Subject open to discussion.

5) the max line length used in Libervia is 90 chars, not 110, please update pylint and flake8 confs. Globally the style used is compatible with black, thus I'm not sure if flake8 is useful at all.

6) I'm not against a XEP-0082 plugin. However, the fact that a duplicate method exists can cause inconsistencies in datetime handling in plugins. Maybe is would make sense to rebase xmpp_date on your code and make XEP-0082 plugin a simple wrapper around it? Just wondering.

7) I see that you use "SAT" in comments (probably a copy-paste of other plugin. Following the renaming of the project, please use "Libervia" everywhere where it's possible now. You have also copy/paster my name on top of XEP-0420 implementation and other files (like tests), but it should be yours ;)

8) Doesn't xmlschema overlap with lxml? I'm wondering because a new dependency can always cause trouble for some platforms like Android or Web. If not, we'll deal with it.

9) It doesn't look right to have twisted/plugins/dropin.cache in the repos.

Beside mostly those style things, your MR looks great, congrats.

S

syndace 24/08/2022, 09:37

Hi,

thanks for the feedback!

1) The overlap is surprisingly small. pylint checks a LOT of things that go way beyond types and doesn't do much type checking at all, while mypy is all about types and catches loads of type errors that pylint doesn't.

3) The underscore is a reserved keyword with special meaning in many programming languages (including Python!) and should absolutely be avoided as a variable identifier. In the Python interpreter, the variable '_' is set to the result of the last command that was interpreted. With structural pattern matching added in Python 3.10 (https://peps.python.org/pep-0634/), the underscore became the wildcard pattern in cases to match values that you are not interested in. The latter are also the semantics used by most programming languages for the underscore. When matching or unpacking values, the underscore is used for values that you don't need, for example you would use foo, bar, _ = returns_three_values() if you are not interested in the third value returned by the function. The fact that the underscore was available to be used as a normal variable identifier is IMO a huge mistake by Python's language design and we should aim to move away from it.

5) I didn't see black was used (not listed in dev-requirements.txt), I'll remove/replace flake8 then.

6) Yes, agreed. XEP-0082 is a more complete implementation of the specification, so I would argue for calling XEP-0082 methods from xmpp_date for now, and in the long run removing xmpp_date completely.

7) I do not, I am always careful to use Libervia in language, chat and code. I only use SAT when I'm referring to the class SAT.

8) Oh, I didn't expect you to have a dependency on lxml with everything being domish.Element everywhere. I think lxml can do schema validation too, so yeah, might overlap. I'll look into it and remove the dependency on xmlschema if possible.

9) I have no idea how I missed that, thanks.

And now about the style aspects in 2), 4) and 5). I'm a bit unwilling to follow coding styles that I consider outdated or worse to read just for the sake of consistency. I know this is highly subjective, but I'd like to discuss every single of the style changes you requested.

The doc style I'm using is called napoleon and is supported by the sphinx doc generator: https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html
The JavaDoc style, in my opinion, looks more bloated and contains pointless punctuation. Overall my feeling is that JavaDoc was built for machines to parse, while the napoleon style was built for humans to read. Since napoleon is supported by sphinx too and is as clear and easy to understand as JavaDoc, I'd like to request keeping the style for my contributions.

For snake_casing: yes, let's start with this right now. The best way to start with a style change is with new files that were completely written in the new style consistently.

About the line length: I really don't know how and why people nowadays force themselves to 90 or fewer chars. Everybody has a 16:9 monitor nowadays (or wider), and even the limit of 110 chars leaves a good portion of my IDE's width unused. Shorter lines mean more awkward linebreaks that make the code less enjoyable to read overall. But, this is the style change of the three you requested that I am most willing to make, I'd like to hear your thought behind the 90 limit first though.

G

goffi 24/08/2022, 10:17

1) OK, let's keep it for now. I may change later notably when moving to Poetry. For the record, I'm using neovim + CoC + PyRight on my dev machine, and it's handling several tools at once

3) it's the convention to use _ for gettext in many language including Python, even official doc does it: https://docs.python.org/fr/3/library/gettext.html#gettext.NullTranslations.install . I know of course the use of _ for unneeded values, but when gettext is used, the convention is to use __ (double underscore). Also it's used like that in the rest of the code base, and if we want to refactor it later to use something else, it needs to be the same everywhere. So please, use _ and D_ for now.

5) it is not used yet, just on TODO list, but black has been applied to a good part of the code. Including it in the workflow is something postponed by lack of time (need to check config, add relevant HG hooks, check that nothing is broken, etc.). I hope to include it soon after moving to Poetry. For now you can use black locally (and add it to dev-requirements.txt if you which).

6) my idea was more to move the implementation to a specific module in tools, and base XEP-0082 plugin on it. The reason is that tools are always available, while a plugin can be unavailable/disabled, and XMPP date/time manipulation is so common that it should be always available.

7) look at top comment of plugin_xep_0420.py it's written SAT plugin for OMEMO encryption (probably a copy paste). Same in plugin_xep_0082.py where it's written SAT plugin for XMPP Date and Time […]. Also my name is used instead of yours. Sorry no way to links directly patch location yet :-p

8) lxml is used to clean XHTML in syntax plugin, for its xpath implementation, and in some frontends because they may not use Twisted. domish.Element is used because it's included with Twisted and the base of its XMPP implementation, but lxml is far more complete.

Regarding coding style, the current docstring format is handled in the doc which uses Sphinx (with minor automatic preprocessing). I know about Napoleon style and dislike it mainly because of the extra indendation is causes (and I don't find it so readable, but it's a question of taste).

Also it's easier to grep for specific params with @param [somename]:.

But mostly, it's a really bad idea to use different styles in the same code base, thus I strongly disagree with using Napoleon style now. I'm not against modifying it later, but the style must be consistent so it can be automatically processed by a regex, a small script, or a refactoring tool. Thus please follow current convention for now, and we can discuss and change it in the whole codebase at a later point (probably after the grant, to avoid loosing time with it now).

OK for snake_case, I'm fine with using it now.

About line length, again there is a question of consistency with the rest of the code.

Sure we have often large screens nowadays, but they are splitted (I often split mine in 3 or more parts), and long lines make the thing difficult to read in this case.

Occasionally we may have to read some code on a phone screen (e.g. to review a MR on the go), and again large lines make the thing really difficult.

I was thinking like you in the past, but have change my mind with time: it's still useful to have a line lenght around ~80/90 chars nowadays. It could be fine if text editors were indenting long lines according to code location, but none is doing that as far as I know.

From my experience, 90 is a good compromise, as 79/80 is really too short IMHO, and longer lines are difficult on splitted windows or small screens.

Thanks!

S

syndace 24/08/2022, 10:29

All of that is reasonable, thanks for the insights. I'll do the changes and update the mr.

G

goffi 24/08/2022, 10:35

thanks!

S

syndace 24/08/2022, 15:10

Updated with all requested changes. There is now a new module sat.tools.datetime, which includes the formatting and parsing methods that were previously part of XEP-0082. XEP-0082 now simply reexports those, and xmpp_date uses them too, so we have a single source of truth.

S

syndace 24/08/2022, 15:51

I have squashed the mr

id

7

author

Tim

created

2022-08-23T11:04:44Z

updated

2022-08-24T21:23:34Z

labels
core
status
closed

plugin XEP-0420: Implementation of Stanza Content Encryption: Includes implementation of XEP-0082 (XMPP date and time profiles) and tests for both new plugins. Everything is type checked, linted, format checked and unit tested. Adds new dependency xmlschema. fix 377

diff --git a/.hgignore b/.hgignore
--- a/.hgignore
+++ b/.hgignore
@@ -18,3 +18,8 @@
 Session.vim
 .build
 .pytest_cache
+env/
+.eggs/
+libervia_backend.egg-info/
+.mypy_cache/
+twisted/plugins/dropin.cache
diff --git a/dev-requirements.txt b/dev-requirements.txt
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -1,4 +1,8 @@
 -r requirements.txt
 
+lxml-stubs
+
+mypy
+pylint
 pytest
 pytest_twisted
diff --git a/pylintrc b/pylintrc
new file mode 100644
--- /dev/null
+++ b/pylintrc
@@ -0,0 +1,498 @@
+[MASTER]
+
+# A comma-separated list of package or module names from where C extensions may
+# be loaded. Extensions are loading into the active Python interpreter and may
+# run arbitrary code.
+extension-pkg-allow-list=lxml.etree
+
+# Add files or directories to the blacklist. They should be base names, not
+# paths.
+ignore=CVS
+
+# Add files or directories matching the regex patterns to the blacklist. The
+# regex matches against base names, not paths.
+ignore-patterns=
+
+# Python code to execute, usually for sys.path manipulation such as
+# pygtk.require().
+#init-hook=
+
+# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
+# number of processors available to use.
+jobs=1
+
+# Control the amount of potential inferred values when inferring a single
+# object. This can help the performance when dealing with large functions or
+# complex, nested conditions.
+limit-inference-results=100
+
+# List of plugins (as comma separated values of python module names) to load,
+# usually to register additional checkers.
+load-plugins=
+
+# Pickle collected data for later comparisons.
+persistent=yes
+
+# Specify a configuration file.
+#rcfile=
+
+# When enabled, pylint would attempt to guess common misconfiguration and emit
+# user-friendly hints instead of false-positive error messages.
+suggestion-mode=yes
+
+# Allow loading of arbitrary C extensions. Extensions are imported into the
+# active Python interpreter and may run arbitrary code.
+unsafe-load-any-extension=no
+
+
+[MESSAGES CONTROL]
+
+# Only show warnings with the listed confidence levels. Leave empty to show
+# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED.
+confidence=
+
+# Disable the message, report, category or checker with the given id(s). You
+# can either give multiple identifiers separated by comma (,) or put this
+# option multiple times (only on the command line, not in the configuration
+# file where it should appear only once). You can also use "--disable=all" to
+# disable everything first and then reenable specific checks. For example, if
+# you want to run only the similarities checker, you can use "--disable=all
+# --enable=similarities". If you want to run only the classes checker, but have
+# no Warning level messages displayed, use "--disable=all --enable=classes
+# --disable=W".
+disable=missing-module-docstring,
+        duplicate-code,
+        fixme,
+        logging-fstring-interpolation
+
+# Enable the message, report, category or checker with the given id(s). You can
+# either give multiple identifier separated by comma (,) or put this option
+# multiple time (only on the command line, not in the configuration file where
+# it should appear only once). See also the "--disable" option for examples.
+enable=useless-suppression
+
+
+[REPORTS]
+
+# Python expression which should return a score less than or equal to 10. You
+# have access to the variables 'error', 'warning', 'refactor', and 'convention'
+# which contain the number of messages in each category, as well as 'statement'
+# which is the total number of statements analyzed. This score is used by the
+# global evaluation report (RP0004).
+evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
+
+# Template used to display messages. This is a python new-style format string
+# used to format the message information. See doc for all details.
+#msg-template=
+
+# Set the output format. Available formats are text, parseable, colorized, json
+# and msvs (visual studio). You can also give a reporter class, e.g.
+# mypackage.mymodule.MyReporterClass.
+output-format=text
+
+# Tells whether to display a full report or only the messages.
+reports=no
+
+# Activate the evaluation score.
+score=yes
+
+
+[REFACTORING]
+
+# Maximum number of nested blocks for function / method body
+max-nested-blocks=5
+
+# Complete name of functions that never returns. When checking for
+# inconsistent-return-statements if a never returning function is called then
+# it will be considered as an explicit return statement and no message will be
+# printed.
+never-returning-functions=sys.exit
+
+
+[SPELLING]
+
+# Limits count of emitted suggestions for spelling mistakes.
+max-spelling-suggestions=4
+
+# Spelling dictionary name. Available dictionaries: none. To make it work,
+# install the python-enchant package.
+spelling-dict=
+
+# List of comma separated words that should not be checked.
+spelling-ignore-words=
+
+# A path to a file that contains the private dictionary; one word per line.
+spelling-private-dict-file=
+
+# Tells whether to store unknown words to the private dictionary (see the
+# --spelling-private-dict-file option) instead of raising a message.
+spelling-store-unknown-words=no
+
+
+[VARIABLES]
+
+# List of additional names supposed to be defined in builtins. Remember that
+# you should avoid defining new builtins when possible.
+additional-builtins=
+
+# Tells whether unused global variables should be treated as a violation.
+allow-global-unused-variables=no
+
+# List of strings which can identify a callback function by name. A callback
+# name must start or end with one of those strings.
+callbacks=cb_,
+          _cb
+
+# A regular expression matching the name of dummy variables (i.e. expected to
+# not be used).
+dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
+
+# Argument names that match this expression will be ignored. Default to name
+# with leading underscore.
+ignored-argument-names=_.*|^ignored_|^unused_
+
+# Tells whether we should check for unused import in __init__ files.
+init-import=no
+
+# List of qualified module names which can have objects that can redefine
+# builtins.
+redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
+
+
+[LOGGING]
+
+# Format style used to check logging format string. `old` means using %
+# formatting, `new` is for `{}` formatting,and `fstr` is for f-strings.
+logging-format-style=old
+
+# Logging modules to check that the string format arguments are in logging
+# function parameter format.
+logging-modules=logging
+
+
+[FORMAT]
+
+# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
+expected-line-ending-format=
+
+# Regexp for a line that is allowed to be longer than the limit.
+ignore-long-lines=^\s*(# )?<?https?://\S+>?$
+
+# Number of spaces of indent required inside a hanging or continued line.
+indent-after-paren=4
+
+# String used as indentation unit. This is usually "    " (4 spaces) or "\t" (1
+# tab).
+indent-string='    '
+
+# Maximum number of characters on a single line.
+max-line-length=90
+
+# Maximum number of lines in a module.
+max-module-lines=10000
+
+# Allow the body of a class to be on the same line as the declaration if body
+# contains single statement.
+single-line-class-stmt=no
+
+# Allow the body of an if to be on the same line as the test if there is no
+# else.
+single-line-if-stmt=yes
+
+
+[TYPECHECK]
+
+# List of decorators that produce context managers, such as
+# contextlib.contextmanager. Add to this list to register other decorators that
+# produce valid context managers.
+contextmanager-decorators=contextlib.contextmanager
+
+# List of members which are set dynamically and missed by pylint inference
+# system, and so shouldn't trigger E1101 when accessed. Python regular
+# expressions are accepted.
+generated-members=
+
+# Tells whether missing members accessed in mixin class should be ignored. A
+# mixin class is detected if its name ends with "mixin" (case insensitive).
+ignore-mixin-members=yes
+
+# Tells whether to warn about missing members when the owner of the attribute
+# is inferred to be None.
+ignore-none=no
+
+# This flag controls whether pylint should warn about no-member and similar
+# checks whenever an opaque object is returned when inferring. The inference
+# can return multiple potential results while evaluating a Python object, but
+# some branches might not be evaluated, which results in partial inference. In
+# that case, it might be useful to still emit no-member and other checks for
+# the rest of the inferred objects.
+ignore-on-opaque-inference=no
+
+# List of class names for which member attributes should not be checked (useful
+# for classes with dynamically set attributes). This supports the use of
+# qualified names.
+ignored-classes=optparse.Values,thread._local,_thread._local
+
+# List of module names for which member attributes should not be checked
+# (useful for modules/projects where namespaces are manipulated during runtime
+# and thus existing member attributes cannot be deduced by static analysis). It
+# supports qualified module names, as well as Unix pattern matching.
+ignored-modules=
+
+# Show a hint with possible names when a member name was not found. The aspect
+# of finding the hint is based on edit distance.
+missing-member-hint=yes
+
+# The minimum edit distance a name should have in order to be considered a
+# similar match for a missing member name.
+missing-member-hint-distance=1
+
+# The total number of similar names that should be taken in consideration when
+# showing a hint for a missing member.
+missing-member-max-choices=1
+
+# List of decorators that change the signature of a decorated function.
+signature-mutators=
+
+
+[STRING]
+
+# This flag controls whether the implicit-str-concat-in-sequence should
+# generate a warning on implicit string concatenation in sequences defined over
+# several lines.
+check-str-concat-over-line-jumps=no
+
+
+[SIMILARITIES]
+
+# Ignore comments when computing similarities.
+ignore-comments=yes
+
+# Ignore docstrings when computing similarities.
+ignore-docstrings=yes
+
+# Ignore imports when computing similarities.
+ignore-imports=no
+
+# Minimum lines number of a similarity.
+min-similarity-lines=4
+
+
+[BASIC]
+
+# Naming style matching correct argument names.
+argument-naming-style=snake_case
+
+# Regular expression matching correct argument names. Overrides argument-
+# naming-style.
+#argument-rgx=
+
+# Naming style matching correct attribute names.
+attr-naming-style=snake_case
+
+# Regular expression matching correct attribute names. Overrides attr-naming-
+# style.
+#attr-rgx=
+
+# Bad variable names which should always be refused, separated by a comma.
+bad-names=foo,
+          bar,
+          baz,
+          toto,
+          tutu,
+          tata
+
+# Naming style matching correct class attribute names.
+class-attribute-naming-style=UPPER_CASE
+
+# Regular expression matching correct class attribute names. Overrides class-
+# attribute-naming-style.
+#class-attribute-rgx=
+
+# Naming style matching correct class names.
+class-naming-style=PascalCase
+
+# Regular expression matching correct class names. Overrides class-naming-
+# style.
+#class-rgx=
+
+# Naming style matching correct constant names.
+const-naming-style=any
+
+# Regular expression matching correct constant names. Overrides const-naming-
+# style.
+#const-rgx=
+
+# Minimum line length for functions/classes that require docstrings, shorter
+# ones are exempt.
+docstring-min-length=-1
+
+# Naming style matching correct function names.
+function-naming-style=snake_case
+
+# Regular expression matching correct function names. Overrides function-
+# naming-style.
+#function-rgx=
+
+# Good variable names which should always be accepted, separated by a comma.
+good-names=i,
+           j,
+           e, # exceptions in except blocks
+           _
+
+# Include a hint for the correct naming format with invalid-name.
+include-naming-hint=no
+
+# Naming style matching correct inline iteration names.
+inlinevar-naming-style=any
+
+# Regular expression matching correct inline iteration names. Overrides
+# inlinevar-naming-style.
+#inlinevar-rgx=
+
+# Naming style matching correct method names.
+method-naming-style=snake_case
+
+# Regular expression matching correct method names. Overrides method-naming-
+# style.
+#method-rgx=
+
+# Naming style matching correct module names.
+module-naming-style=snake_case
+
+# Regular expression matching correct module names. Overrides module-naming-
+# style.
+#module-rgx=
+
+# Colon-delimited sets of names that determine each other's naming style when
+# the name regexes allow several styles.
+name-group=
+
+# Regular expression which should only match function or class names that do
+# not require a docstring.
+no-docstring-rgx=^_
+
+# List of decorators that produce properties, such as abc.abstractproperty. Add
+# to this list to register other decorators that produce valid properties.
+# These decorators are taken in consideration only for invalid-name.
+property-classes=abc.abstractproperty
+
+# Naming style matching correct variable names.
+variable-naming-style=snake_case
+
+# Regular expression matching correct variable names. Overrides variable-
+# naming-style.
+#variable-rgx=
+
+
+[MISCELLANEOUS]
+
+# List of note tags to take in consideration, separated by a comma.
+notes=FIXME,
+      XXX,
+      TODO
+
+
+[IMPORTS]
+
+# List of modules that can be imported at any level, not just the top level
+# one.
+allow-any-import-level=
+
+# Allow wildcard imports from modules that define __all__.
+allow-wildcard-with-all=no
+
+# Analyse import fallback blocks. This can be used to support both Python 2 and
+# 3 compatible code, which means that the block might have code that exists
+# only in one or another interpreter, leading to false positives when analysed.
+analyse-fallback-blocks=yes
+
+# Deprecated modules which should not be used, separated by a comma.
+deprecated-modules=optparse,tkinter.tix
+
+# Create a graph of external dependencies in the given file (report RP0402 must
+# not be disabled).
+ext-import-graph=
+
+# Create a graph of every (i.e. internal and external) dependencies in the
+# given file (report RP0402 must not be disabled).
+import-graph=
+
+# Create a graph of internal dependencies in the given file (report RP0402 must
+# not be disabled).
+int-import-graph=
+
+# Force import order to recognize a module as part of the standard
+# compatibility libraries.
+known-standard-library=
+
+# Force import order to recognize a module as part of a third party library.
+known-third-party=enchant
+
+# Couples of modules and preferred modules, separated by a comma.
+preferred-modules=
+
+
+[CLASSES]
+
+# List of method names used to declare (i.e. assign) instance attributes.
+defining-attr-methods=__init__,
+                      __new__,
+                      setUp,
+                      __post_init__,
+                      create
+
+# List of member names, which should be excluded from the protected access
+# warning.
+exclude-protected=_asdict,
+                  _fields,
+                  _replace,
+                  _source,
+                  _make
+
+# List of valid names for the first argument in a class method.
+valid-classmethod-first-arg=cls
+
+# List of valid names for the first argument in a metaclass class method.
+valid-metaclass-classmethod-first-arg=cls
+
+
+[DESIGN]
+
+# Maximum number of arguments for function / method.
+max-args=100
+
+# Maximum number of attributes for a class (see R0902).
+max-attributes=100
+
+# Maximum number of boolean expressions in an if statement (see R0916).
+max-bool-expr=10
+
+# Maximum number of branch for function / method body.
+max-branches=100
+
+# Maximum number of locals for function / method body.
+max-locals=100
+
+# Maximum number of parents for a class (see R0901).
+max-parents=10
+
+# Maximum number of public methods for a class (see R0904).
+max-public-methods=100
+
+# Maximum number of return / yield for function / method body.
+max-returns=100
+
+# Maximum number of statements in function / method body.
+max-statements=1000
+
+# Minimum number of public methods for a class (see R0903).
+min-public-methods=0
+
+
+[EXCEPTIONS]
+
+# Exceptions that will emit a warning when being caught. Defaults to
+# "BaseException, Exception".
+overgeneral-exceptions=BaseException,
+                       Exception
diff --git a/sat/core/i18n.py b/sat/core/i18n.py
--- a/sat/core/i18n.py
+++ b/sat/core/i18n.py
@@ -17,6 +17,7 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+from typing import Callable, cast
 
 from sat.core.log import getLogger
 
@@ -40,10 +41,10 @@
 except ImportError:
 
     log.warning("gettext support disabled")
-    _ = lambda msg: msg  # Libervia doesn't support gettext
+    _ = cast(Callable[[str], str], lambda msg: msg)  # Libervia doesn't support gettext
 
     def languageSwitch(lang=None):
         pass
 
 
-D_ = lambda msg: msg  # used for deferred translations
+D_ = cast(Callable[[str], str], lambda msg: msg)  # used for deferred translations
diff --git a/sat/plugins/plugin_xep_0082.py b/sat/plugins/plugin_xep_0082.py
new file mode 100644
--- /dev/null
+++ b/sat/plugins/plugin_xep_0082.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for XMPP Date and Time Profile formatting and parsing with Python's
+# datetime package
+# Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+# Type-check with `mypy --strict`
+# Lint with `pylint`
+
+from sat.core.constants import Const as C
+from sat.core.i18n import D_
+from sat.core.sat_main import SAT
+from sat.tools import datetime
+
+
+__all__ = [  # pylint: disable=unused-variable
+    "PLUGIN_INFO",
+    "XEP_0082"
+]
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XMPP Date and Time Profiles",
+    C.PI_IMPORT_NAME: "XEP-0082",
+    C.PI_TYPE: C.PLUG_TYPE_MISC,
+    C.PI_PROTOCOLS: [ "XEP-0082" ],
+    C.PI_DEPENDENCIES: [],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "XEP_0082",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: D_("Date and Time Profiles for XMPP"),
+}
+
+
+class XEP_0082:  # pylint: disable=invalid-name
+    """
+    Implementation of the date and time profiles specified in XEP-0082 using Python's
+    datetime module. The legacy format described in XEP-0082 section "4. Migration" is not
+    supported. Reexports of the functions in :mod:`sat.tools.datetime`.
+
+    This is a passive plugin, i.e. it doesn't hook into any triggers to process stanzas
+    actively, but offers API for other plugins to use.
+    """
+
+    def __init__(self, sat: SAT) -> None:
+        """
+        @param sat: The SAT instance.
+        """
+
+    format_date = staticmethod(datetime.format_date)
+    parse_date = staticmethod(datetime.parse_date)
+    format_datetime = staticmethod(datetime.format_datetime)
+    parse_datetime = staticmethod(datetime.parse_datetime)
+    format_time = staticmethod(datetime.format_time)
+    parse_time = staticmethod(datetime.parse_time)
diff --git a/sat/plugins/plugin_xep_0420.py b/sat/plugins/plugin_xep_0420.py
new file mode 100644
--- /dev/null
+++ b/sat/plugins/plugin_xep_0420.py
@@ -0,0 +1,582 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for Stanza Content Encryption
+# Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+# Type-check with `mypy --strict --disable-error-code no-untyped-call`
+# Lint with `pylint`
+
+from abc import ABC, abstractmethod
+from datetime import datetime
+import enum
+import secrets
+import string
+from typing import Dict, Iterator, List, NamedTuple, Optional, Set, Tuple, Union, cast
+
+from lxml import etree
+
+from sat.core.constants import Const as C
+from sat.core.i18n import D_
+from sat.core.log import Logger, getLogger
+from sat.core.sat_main import SAT
+from sat.tools.xml_tools import ElementParser
+from sat.plugins.plugin_xep_0033 import NS_ADDRESS
+from sat.plugins.plugin_xep_0082 import XEP_0082
+from sat.plugins.plugin_xep_0334 import NS_HINTS
+from sat.plugins.plugin_xep_0359 import NS_SID
+from sat.plugins.plugin_xep_0380 import NS_EME
+from twisted.words.protocols.jabber import jid
+from twisted.words.xish import domish
+
+
+__all__ = [  # pylint: disable=unused-variable
+    "PLUGIN_INFO",
+    "NS_SCE",
+    "XEP_0420",
+    "ProfileRequirementsNotMet",
+    "AffixVerificationFailed",
+    "SCECustomAffix",
+    "SCEAffixPolicy",
+    "SCEProfile",
+    "SCEAffixValues"
+]
+
+
+log = cast(Logger, getLogger(__name__))
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "SCE",
+    C.PI_IMPORT_NAME: "XEP-0420",
+    C.PI_TYPE: "SEC",
+    C.PI_PROTOCOLS: [ "XEP-0420" ],
+    C.PI_DEPENDENCIES: [ "XEP-0334", "XEP-0082" ],
+    C.PI_RECOMMENDATIONS: [ "XEP-0045", "XEP-0033", "XEP-0359" ],
+    C.PI_MAIN: "XEP_0420",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: D_("Implementation of Stanza Content Encryption"),
+}
+
+
+NS_SCE = "urn:xmpp:sce:1"
+
+
+class ProfileRequirementsNotMet(Exception):
+    """
+    Raised by :meth:`XEP_0420.unpack_stanza` in case the requirements formulated by the
+    profile are not met.
+    """
+
+
+class AffixVerificationFailed(Exception):
+    """
+    Raised by :meth:`XEP_0420.unpack_stanza` in case of affix verification failure.
+    """
+
+
+class SCECustomAffix(ABC):
+    """
+    Interface for custom affixes of SCE profiles.
+    """
+
+    @property
+    @abstractmethod
+    def element_name(self) -> str:
+        """
+        @return: The name of the affix's XML element.
+        """
+
+    @property
+    @abstractmethod
+    def element_schema(self) -> str:
+        """
+        @return: The XML schema definition of the affix element's XML structure, i.e. the
+            ``<xs:element/>`` schema element. This element will be referenced using
+            ``<xs:element ref="{element_name}"/>``.
+        """
+
+    @abstractmethod
+    def create(self, stanza: domish.Element) -> domish.Element:
+        """
+        @param stanza: The stanza element which has been processed by
+            :meth:`XEP_0420.pack_stanza`, i.e. all encryptable children have been removed
+            and only the root ``<message/>`` or ``<iq/>`` and unencryptable children
+            remain. Do not modify.
+        @return: An affix element to include in the envelope. The element must have the
+            name :attr:`element_name` and must validate using :attr:`element_schema`.
+        @raise ValueError: if the affix couldn't be built.
+        """
+
+    @abstractmethod
+    def verify(self, stanza: domish.Element, element: domish.Element) -> None:
+        """
+        @param stanza: The stanza element before being processed by
+            :meth:`XEP_0420.unpack_stanza`, i.e. all encryptable children have been
+            removed and only the root ``<message/>`` or ``<iq/>`` and unencryptable
+            children remain. Do not modify.
+        @param element: The affix element to verify.
+        @raise AffixVerificationFailed: on verification failure.
+        """
+
+
+@enum.unique
+class SCEAffixPolicy(enum.Enum):
+    """
+    Policy for the presence of an affix in an SCE envelope.
+    """
+
+    REQUIRED: str = "REQUIRED"
+    OPTIONAL: str = "OPTIONAL"
+    NOT_NEEDED: str = "NOT_NEEDED"
+
+
+class SCEProfile(NamedTuple):
+    # pylint: disable=invalid-name
+    """
+    An SCE profile, i.e. the definition which affixes are required, optional or not needed
+    at all by an SCE-enabled encryption protocol.
+    """
+
+    rpad_policy: SCEAffixPolicy
+    time_policy: SCEAffixPolicy
+    to_policy: SCEAffixPolicy
+    from_policy: SCEAffixPolicy
+    custom_policies: Dict[SCECustomAffix, SCEAffixPolicy]
+
+
+class SCEAffixValues(NamedTuple):
+    # pylint: disable=invalid-name
+    """
+    Structure returned by :meth:`XEP_0420.unpack_stanza` with the parsed/processes values
+    of all affixes included in the envelope. For custom affixes, the whole affix element
+    is returned.
+    """
+
+    rpad: Optional[str]
+    timestamp: Optional[datetime]
+    recipient: Optional[jid.JID]
+    sender: Optional[jid.JID]
+    custom: Dict[SCECustomAffix, domish.Element]
+
+
+ENVELOPE_SCHEMA = """<?xml version="1.0" encoding="utf8"?>
+<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
+    targetNamespace="urn:xmpp:sce:1"
+    xmlns="urn:xmpp:sce:1">
+
+    <xs:element name="envelope">
+        <xs:complexType>
+            <xs:all>
+                <xs:element ref="content"/>
+                <xs:element ref="rpad" minOccurs="0"/>
+                <xs:element ref="time" minOccurs="0"/>
+                <xs:element ref="to" minOccurs="0"/>
+                <xs:element ref="from" minOccurs="0"/>
+                {custom_affix_references}
+            </xs:all>
+        </xs:complexType>
+    </xs:element>
+
+    <xs:element name="content">
+        <xs:complexType>
+            <xs:sequence>
+                <xs:any minOccurs="0" maxOccurs="unbounded" processContents="skip"/>
+            </xs:sequence>
+        </xs:complexType>
+    </xs:element>
+
+    <xs:element name="rpad" type="xs:string"/>
+
+    <xs:element name="time">
+        <xs:complexType>
+            <xs:attribute name="stamp" type="xs:dateTime"/>
+        </xs:complexType>
+    </xs:element>
+
+    <xs:element name="to">
+        <xs:complexType>
+            <xs:attribute name="jid" type="xs:string"/>
+        </xs:complexType>
+    </xs:element>
+
+    <xs:element name="from">
+        <xs:complexType>
+            <xs:attribute name="jid" type="xs:string"/>
+        </xs:complexType>
+    </xs:element>
+
+    {custom_affix_definitions}
+</xs:schema>
+"""
+
+
+class XEP_0420:  # pylint: disable=invalid-name
+    """
+    Implementation of XEP-0420: Stanza Content Encryption under namespace
+    ``urn:xmpp:sce:1``.
+
+    This is a passive plugin, i.e. it doesn't hook into any triggers to process stanzas
+    actively, but offers API for other plugins to use.
+    """
+
+    # Set of namespaces whose elements are never allowed to be transferred in an encrypted
+    # envelope.
+    MUST_BE_PLAINTEXT_NAMESPACES: Set[str] = {
+        NS_HINTS,
+        NS_SID,  # TODO: Not sure whether this ban applies to both stanza-id and origin-id
+        NS_ADDRESS,
+        # Not part of the specification (yet), but just doesn't make sense in an encrypted
+        # envelope:
+        NS_EME
+    }
+
+    # Set of (namespace, element name) tuples that define elements which are never allowed
+    # to be transferred in an encrypted envelope. If all elements under a certain
+    # namespace are forbidden, the namespace can be added to
+    # :attr:`MUST_BE_PLAINTEXT_NAMESPACES` instead.
+    # Note: only full namespaces are forbidden by the spec for now, the following is for
+    # potential future use.
+    MUST_BE_PLAINTEXT_ELEMENTS: Set[Tuple[str, str]] = set()
+
+    def __init__(self, sat: SAT) -> None:
+        """
+        @param sat: The SAT instance.
+        """
+
+    @staticmethod
+    def pack_stanza(profile: SCEProfile, stanza: domish.Element) -> bytes:
+        """Pack a stanza according to Stanza Content Encryption.
+
+        Removes all elements from the stanza except for a few exceptions that explicitly
+        need to be transferred in plaintext, e.g. because they contain hints/instructions
+        for the server on how to process the stanza. Together with the affix elements as
+        requested by the profile, the removed elements are added to an envelope XML
+        structure that builds the plaintext to be encrypted by the SCE-enabled encryption
+        scheme. Optional affixes are always added to the structure, i.e. they are treated
+        by the packing code as if they were required.
+
+        Once built, the envelope structure is serialized to a byte string and returned for
+        the encryption scheme to encrypt and add to the stanza.
+
+        @param profile: The SCE profile, i.e. the definition of affixes to include in the
+            envelope.
+        @param stanza: The stanza to process. Will be modified by the call.
+        @return: The serialized envelope structure that builds the plaintext for the
+            encryption scheme to process.
+        @raise ValueError: if the <to/> or <from/> affixes are requested but the stanza
+            doesn't have the "to"/"from" attribute set to extract the value from. Can also
+            be raised by custom affixes.
+
+        @warning: It is up to the calling code to add a <store/> message processing hint
+            if applicable.
+        """
+
+        # Prepare the envelope and content elements
+        envelope = domish.Element((NS_SCE, "envelope"))
+        content = envelope.addElement((NS_SCE, "content"))
+
+        # Note the serialized byte size of the content element before adding any children
+        empty_content_byte_size = len(content.toXml().encode("utf-8"))
+
+        # Just for type safety
+        stanza_children = cast(List[Union[domish.Element, str]], stanza.children)
+        content_children = cast(List[Union[domish.Element, str]], content.children)
+
+        # Move elements that are not explicitly forbidden from being encrypted from the
+        # stanza to the content element.
+        for child in list(cast(Iterator[domish.Element], stanza.elements())):
+            if (
+                child.uri not in XEP_0420.MUST_BE_PLAINTEXT_NAMESPACES
+                and (child.uri, child.name) not in XEP_0420.MUST_BE_PLAINTEXT_ELEMENTS
+            ):
+                # Remove the child from the stanza
+                stanza_children.remove(child)
+
+                # A namespace of ``None`` can be used on domish elements to inherit the
+                # namespace from the parent. When moving elements from the stanza root to
+                # the content element, however, we don't want elements to inherit the
+                # namespace of the content element. Thus, check for elements with ``None``
+                # for their namespace and set the namespace to jabber:client, which is the
+                # namespace of the parent element.
+                if child.uri is None:
+                    child.uri = C.NS_CLIENT
+                    child.defaultUri = C.NS_CLIENT
+
+                # Add the child with corrected namespaces to the content element
+                content_children.append(child)
+
+        # Add the affixes requested by the profile
+        if profile.rpad_policy is not SCEAffixPolicy.NOT_NEEDED:
+            # The specification defines the rpad affix to contain "[...] a randomly
+            # generated sequence of random length between 0 and 200 characters." This
+            # implementation differs a bit from the specification in that a minimum size
+            # other than 0 is chosen depending on the serialized size of the content
+            # element. This is to prevent the scenario where the encrypted content is
+            # short and the rpad is also randomly chosen to be short, which could allow
+            # guessing the content of a short message. To do so, the rpad length is first
+            # chosen to pad the content to at least 53 bytes, then afterwards another 0 to
+            # 200 bytes are added. Note that single-byte characters are used by this
+            # implementation, thus the number of characters equals the number of bytes.
+            content_byte_size = len(content.toXml().encode("utf-8"))
+            content_byte_size_diff = content_byte_size - empty_content_byte_size
+            rpad_length = max(0, 53 - content_byte_size_diff) + secrets.randbelow(201)
+            rpad_content = "".join(
+                secrets.choice(string.digits + string.ascii_letters + string.punctuation)
+                for __
+                in range(rpad_length)
+            )
+            envelope.addElement((NS_SCE, "rpad"), content=rpad_content)
+
+        if profile.time_policy is not SCEAffixPolicy.NOT_NEEDED:
+            time_element = envelope.addElement((NS_SCE, "time"))
+            time_element["stamp"] = XEP_0082.format_datetime()
+
+        if profile.to_policy is not SCEAffixPolicy.NOT_NEEDED:
+            recipient = cast(Optional[str], stanza.getAttribute("to", None))
+            if recipient is None:
+                raise ValueError(
+                    "<to/> affix requested, but stanza doesn't have the 'to' attribute"
+                    " set."
+                )
+
+            to_element = envelope.addElement((NS_SCE, "to"))
+            to_element["jid"] = jid.JID(recipient).userhost()
+
+        if profile.from_policy is not SCEAffixPolicy.NOT_NEEDED:
+            sender = cast(Optional[str], stanza.getAttribute("from", None))
+            if sender is None:
+                raise ValueError(
+                    "<from/> affix requested, but stanza doesn't have the 'from'"
+                    " attribute set."
+                )
+
+            from_element = envelope.addElement((NS_SCE, "from"))
+            from_element["jid"] = jid.JID(sender).userhost()
+
+        for affix, policy in profile.custom_policies.items():
+            if policy is not SCEAffixPolicy.NOT_NEEDED:
+                envelope.addChild(affix.create(stanza))
+
+        return cast(str, envelope.toXml()).encode("utf-8")
+
+    @staticmethod
+    def unpack_stanza(
+        profile: SCEProfile,
+        stanza: domish.Element,
+        envelope_serialized: bytes
+    ) -> SCEAffixValues:
+        """Unpack a stanza packed according to Stanza Content Encryption.
+
+        Parses the serialized envelope as XML, verifies included affixes and makes sure
+        the requirements of the profile are met, and restores the stanza by moving
+        decrypted elements from the envelope back to the stanza top level.
+
+        @param profile: The SCE profile, i.e. the definition of affixes that have to/may
+            be included in the envelope.
+        @param stanza: The stanza to process. Will be modified by the call.
+        @param envelope_serialized: The serialized envelope, i.e. the plaintext produced
+            by the decryption scheme utilizing SCE.
+        @return: The parsed and processed values of all affixes that were present on the
+            envelope, notably including the timestamp.
+        @raise ValueError: if the serialized envelope element is malformed.
+        @raise ProfileRequirementsNotMet: if one or more affixes required by the profile
+            are missing from the envelope.
+        @raise AffixVerificationFailed: if an affix included in the envelope fails to
+            validate. It doesn't matter whether the affix is required by the profile or
+            not, all affixes included in the envelope are validated and cause this
+            exception to be raised on failure.
+
+        @warning: It is up to the calling code to verify the timestamp, if returned, since
+            the requirements on the timestamp may vary between SCE-enabled protocols.
+        """
+
+        try:
+            envelope_serialized_string = envelope_serialized.decode("utf-8")
+        except UnicodeError as e:
+            raise ValueError("Serialized envelope can't bare parsed as utf-8.") from e
+
+        custom_affixes = set(profile.custom_policies.keys())
+
+        # Make sure the envelope adheres to the schema
+        parser = etree.XMLParser(schema=etree.XMLSchema(etree.XML(ENVELOPE_SCHEMA.format(
+            custom_affix_references="".join(
+                f'<xs:element ref="{custom_affix.element_name}" minOccurs="0"/>'
+                for custom_affix
+                in custom_affixes
+            ),
+            custom_affix_definitions="".join(
+                custom_affix.element_schema
+                for custom_affix
+                in custom_affixes
+            )
+        ).encode("utf-8"))))
+
+        try:
+            etree.fromstring(envelope_serialized_string, parser)
+        except etree.XMLSyntaxError as e:
+            raise ValueError("Serialized envelope doesn't pass schema validation.") from e
+
+        # Prepare the envelope and content elements
+        envelope = cast(domish.Element, ElementParser()(envelope_serialized_string))
+        content = cast(domish.Element, next(envelope.elements(NS_SCE, "content")))
+
+        # Verify the affixes
+        rpad_element = cast(
+            Optional[domish.Element],
+            next(envelope.elements(NS_SCE, "rpad"), None)
+        )
+        time_element = cast(
+            Optional[domish.Element],
+            next(envelope.elements(NS_SCE, "time"), None)
+        )
+        to_element = cast(
+            Optional[domish.Element],
+            next(envelope.elements(NS_SCE, "to"), None)
+        )
+        from_element = cast(
+            Optional[domish.Element],
+            next(envelope.elements(NS_SCE, "from"), None)
+        )
+
+        # The rpad doesn't need verification.
+        rpad_value = None if rpad_element is None else str(rpad_element)
+
+        # The time affix isn't verified other than that the timestamp is parseable.
+        try:
+            timestamp_value = None if time_element is None else \
+                XEP_0082.parse_datetime(time_element["stamp"])
+        except ValueError as e:
+            raise AffixVerificationFailed("Malformed time affix") from e
+
+        # The to affix is verified by comparing the to attribute of the stanza with the
+        # JID referenced by the affix. Note that only bare JIDs are compared as per the
+        # specification.
+        recipient_value: Optional[jid.JID] = None
+        if to_element is not None:
+            recipient_value = jid.JID(to_element["jid"])
+
+            recipient_actual = cast(Optional[str], stanza.getAttribute("to", None))
+            if recipient_actual is None:
+                raise AffixVerificationFailed(
+                    "'To' affix is included in the envelope, but the stanza is lacking a"
+                    " 'to' attribute to compare the value to."
+                )
+
+            recipient_actual_bare_jid = jid.JID(recipient_actual).userhost()
+            recipient_target_bare_jid = recipient_value.userhost()
+
+            if recipient_actual_bare_jid != recipient_target_bare_jid:
+                raise AffixVerificationFailed(
+                    f"Mismatch between actual and target recipient bare JIDs:"
+                    f" {recipient_actual_bare_jid} vs {recipient_target_bare_jid}."
+                )
+
+        # The from affix is verified by comparing the from attribute of the stanza with
+        # the JID referenced by the affix. Note that only bare JIDs are compared as per
+        # the specification.
+        sender_value: Optional[jid.JID] = None
+        if from_element is not None:
+            sender_value = jid.JID(from_element["jid"])
+
+            sender_actual = cast(Optional[str], stanza.getAttribute("from", None))
+            if sender_actual is None:
+                raise AffixVerificationFailed(
+                    "'From' affix is included in the envelope, but the stanza is lacking"
+                    " a 'from' attribute to compare the value to."
+                )
+
+            sender_actual_bare_jid = jid.JID(sender_actual).userhost()
+            sender_target_bare_jid = sender_value.userhost()
+
+            if sender_actual_bare_jid != sender_target_bare_jid:
+                raise AffixVerificationFailed(
+                    f"Mismatch between actual and target sender bare JIDs:"
+                    f" {sender_actual_bare_jid} vs {sender_target_bare_jid}."
+                )
+
+        # Find and verify custom affixes
+        custom_values: Dict[SCECustomAffix, domish.Element] = {}
+        for affix in custom_affixes:
+            element_name = affix.element_name
+            element = cast(
+                Optional[domish.Element],
+                next(envelope.elements(NS_SCE, element_name), None)
+            )
+            if element is not None:
+                affix.verify(stanza, element)
+                custom_values[affix] = element
+
+        # Check whether all affixes required by the profile are present
+        rpad_missing = \
+            profile.rpad_policy is SCEAffixPolicy.REQUIRED and rpad_element is None
+        time_missing = \
+            profile.time_policy is SCEAffixPolicy.REQUIRED and time_element is None
+        to_missing = \
+            profile.to_policy is SCEAffixPolicy.REQUIRED and to_element is None
+        from_missing = \
+            profile.from_policy is SCEAffixPolicy.REQUIRED and from_element is None
+        custom_missing = any(
+            affix not in custom_values
+            for affix, policy
+            in profile.custom_policies.items()
+            if policy is SCEAffixPolicy.REQUIRED
+        )
+
+        if rpad_missing or time_missing or to_missing or from_missing or custom_missing:
+            custom_missing_string = ""
+            for custom_affix in custom_affixes:
+                value = "present" if custom_affix in custom_values else "missing"
+                custom_missing_string += f", [custom]{custom_affix.element_name}={value}"
+
+            raise ProfileRequirementsNotMet(
+                f"SCE envelope is missing affixes required by the profile {profile}."
+                f" Affix presence:"
+                f" rpad={'missing' if rpad_missing else 'present'}"
+                f", time={'missing' if time_missing else 'present'}"
+                f", to={'missing' if to_missing else 'present'}"
+                f", from={'missing' if from_missing else 'present'}"
+                + custom_missing_string
+            )
+
+        # Just for type safety
+        content_children = cast(List[Union[domish.Element, str]], content.children)
+        stanza_children = cast(List[Union[domish.Element, str]], stanza.children)
+
+        # Move elements that are not explicitly forbidden from being encrypted from the
+        # content element to the stanza.
+        for child in list(cast(Iterator[domish.Element], content.elements())):
+            if (
+                child.uri in XEP_0420.MUST_BE_PLAINTEXT_NAMESPACES
+                or (child.uri, child.name) in XEP_0420.MUST_BE_PLAINTEXT_ELEMENTS
+            ):
+                log.warning(
+                    f"An element that MUST be transferred in plaintext was found in an"
+                    f" SCE envelope: {child.toXml()}"
+                )
+            else:
+                # Remove the child from the content element
+                content_children.remove(child)
+
+                # Add the child to the stanza
+                stanza_children.append(child)
+
+        return SCEAffixValues(
+            rpad_value,
+            timestamp_value,
+            recipient_value,
+            sender_value,
+            custom_values
+        )
diff --git a/sat/tools/datetime.py b/sat/tools/datetime.py
new file mode 100644
--- /dev/null
+++ b/sat/tools/datetime.py
@@ -0,0 +1,182 @@
+#!/usr/bin/env python3
+
+# Libervia: XMPP Date and Time profiles as per XEP-0082
+# Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+# Type-check with `mypy --strict`
+# Lint with `pylint`
+
+from datetime import date, datetime, time, timezone
+import re
+from typing import Optional, Tuple
+
+
+__all__ = [  # pylint: disable=unused-variable
+    "format_date",
+    "parse_date",
+    "format_datetime",
+    "parse_datetime",
+    "format_time",
+    "parse_time"
+]
+
+
+def __parse_fraction_of_a_second(value: str) -> Tuple[str, Optional[int]]:
+    """
+    datetime's strptime only supports up to six digits of the fraction of a seconds, while
+    the XEP-0082 specification allows for any number of digits. This function parses and
+    removes the optional fraction of a second from the input string.
+
+    @param value: The input string, containing a section of the format [.sss].
+    @return: The input string with the fraction of a second removed, and the fraction of a
+        second parsed with microsecond resolution. Returns the unaltered input string and
+        ``None`` if no fraction of a second was found in the input string.
+    """
+
+    #  The following regex matches the optional fraction of a seconds for manual
+    # processing.
+    match = re.search(r"\.(\d*)", value)
+    microsecond: Optional[int] = None
+    if match is not None:
+        # Remove the fraction of a second from the input string
+        value = value[:match.start()] + value[match.end():]
+
+        # datetime supports microsecond resolution for the fraction of a second, thus
+        # limit/pad the parsed fraction of a second to six digits
+        microsecond = int(match.group(1)[:6].ljust(6, '0'))
+
+    return value, microsecond
+
+
+def format_date(value: Optional[date] = None) -> str:
+    """
+    @param value: The date for format. Defaults to the current date in the UTC timezone.
+    @return: The date formatted according to the Date profile specified in XEP-0082.
+
+    @warning: Formatting of the current date in the local timezone may leak geographical
+        information of the sender. Thus, it is advised to only format the current date in
+        UTC.
+    """
+    # CCYY-MM-DD
+
+    # The Date profile of XEP-0082 is equal to the ISO 8601 format.
+    return (datetime.now(timezone.utc).date() if value is None else value).isoformat()
+
+
+def parse_date(value: str) -> date:
+    """
+    @param value: A string containing date information formatted according to the Date
+        profile specified in XEP-0082.
+    @return: The date parsed from the input string.
+    @raise ValueError: if the input string is not correctly formatted.
+    """
+    # CCYY-MM-DD
+
+    # The Date profile of XEP-0082 is equal to the ISO 8601 format.
+    return date.fromisoformat(value)
+
+
+def format_datetime(
+    value: Optional[datetime] = None,
+    include_microsecond: bool = False
+) -> str:
+    """
+    @param value: The datetime to format. Defaults to the current datetime.
+    @param include_microsecond: Include the microsecond of the datetime in the output.
+    @return: The datetime formatted according to the DateTime profile specified in
+        XEP-0082. The datetime is always converted to UTC before formatting to avoid
+        leaking geographical information of the sender.
+    """
+    # CCYY-MM-DDThh:mm:ss[.sss]TZD
+
+    # We format the time in UTC, since the %z formatter of strftime doesn't include colons
+    # to separate hours and minutes which is required by XEP-0082. UTC allows us to put a
+    # simple letter 'Z' as the time zone definition.
+    value = (
+        datetime.now(timezone.utc)
+        if value is None
+        else value.astimezone(timezone.utc)  # pylint: disable=no-member
+    )
+
+    if include_microsecond:
+        return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
+
+    return value.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def parse_datetime(value: str) -> datetime:
+    """
+    @param value: A string containing datetime information formatted according to the
+        DateTime profile specified in XEP-0082.
+    @return: The datetime parsed from the input string.
+    @raise ValueError: if the input string is not correctly formatted.
+    """
+    # CCYY-MM-DDThh:mm:ss[.sss]TZD
+
+    value, microsecond = __parse_fraction_of_a_second(value)
+
+    result = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S%z")
+
+    if microsecond is not None:
+        result = result.replace(microsecond=microsecond)
+
+    return result
+
+
+def format_time(value: Optional[time] = None, include_microsecond: bool = False) -> str:
+    """
+    @param value: The time to format. Defaults to the current time in the UTC timezone.
+    @param include_microsecond: Include the microsecond of the time in the output.
+    @return: The time formatted according to the Time profile specified in XEP-0082.
+
+    @warning: Since accurate timezone conversion requires the date to be known, this
+        function cannot convert input times to UTC before formatting. This means that
+        geographical information of the sender may be leaked if a time in local timezone
+        is formatted. Thus, when passing a time to format, it is advised to pass the time
+        in UTC if possible.
+    """
+    # hh:mm:ss[.sss][TZD]
+
+    if value is None:
+        # There is no time.now() method as one might expect, but the current time can be
+        # extracted from a datetime object including time zone information.
+        value = datetime.now(timezone.utc).timetz()
+
+    # The format created by time.isoformat complies with the XEP-0082 Time profile.
+    return value.isoformat("auto" if include_microsecond else "seconds")
+
+
+def parse_time(value: str) -> time:
+    """
+    @param value: A string containing time information formatted according to the Time
+        profile specified in XEP-0082.
+    @return: The time parsed from the input string.
+    @raise ValueError: if the input string is not correctly formatted.
+    """
+    # hh:mm:ss[.sss][TZD]
+
+    value, microsecond = __parse_fraction_of_a_second(value)
+
+    # The format parsed by time.fromisoformat mostly complies with the XEP-0082 Time
+    # profile, except that it doesn't handle the letter Z as time zone information for
+    # UTC. This can be fixed with a simple string replacement of 'Z' with "+00:00", which
+    # is another way to represent UTC.
+    result = time.fromisoformat(value.replace('Z', "+00:00"))
+
+    if microsecond is not None:
+        result = result.replace(microsecond=microsecond)
+
+    return result
diff --git a/sat/tools/utils.py b/sat/tools/utils.py
--- a/sat/tools/utils.py
+++ b/sat/tools/utils.py
@@ -34,6 +34,7 @@
 from twisted.internet import defer
 from sat.core.constants import Const as C
 from sat.core.log import getLogger
+from sat.tools.datetime import format_date, format_datetime
 
 log = getLogger(__name__)
 
@@ -146,18 +147,16 @@
 
     to avoid reveling the timezone, we always return UTC dates
     the string returned by this method is valid with RFC 3339
+    this function redirects to the functions in the :mod:`sat.tools.datetime` module
     @param timestamp(None, float): posix timestamp. If None current time will be used
     @param with_time(bool): if True include the time
     @return(unicode): XEP-0082 formatted date and time
     """
-    template_date = "%Y-%m-%d"
-    template_time = "%H:%M:%SZ"
-    template = (
-        "{}T{}".format(template_date, template_time) if with_time else template_date
+    dtime = datetime.datetime.utcfromtimestamp(
+        time.time() if timestamp is None else timestamp
     )
-    return datetime.datetime.utcfromtimestamp(
-        time.time() if timestamp is None else timestamp
-    ).strftime(template)
+
+    return format_datetime(dtime) if with_time else format_date(dtime.date())
 
 
 def generatePassword(vocabulary=None, size=20):
diff --git a/tests/unit/test_plugin_xep_0082.py b/tests/unit/test_plugin_xep_0082.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/test_plugin_xep_0082.py
@@ -0,0 +1,204 @@
+#!/usr/bin/env python3
+
+# Tests for Libervia's XMPP Date and Time Profile formatting and parsing plugin
+# Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+# Type-check with `mypy --strict`
+# Lint with `pylint`
+
+from datetime import date, datetime, time, timezone
+
+import pytest
+
+from sat.plugins.plugin_xep_0082 import XEP_0082
+
+
+__all__ = [  # pylint: disable=unused-variable
+    "test_date_formatting",
+    "test_date_parsing",
+    "test_datetime_formatting",
+    "test_datetime_parsing",
+    "test_time_formatting",
+    "test_time_parsing"
+]
+
+
+def test_date_formatting() -> None:  # pylint: disable=missing-function-docstring
+    # The following tests don't check the output of format_date directly; instead, they
+    # assume that parse_date rejects incorrect formatting.
+
+    # The date version 0.1 of XEP-0082 was published
+    value = date(2003, 4, 21)
+
+    # Check that the parsed date equals the formatted date
+    assert XEP_0082.parse_date(XEP_0082.format_date(value)) == value
+
+    # Check that a date instance is returned when format_date is called without an
+    # explicit input date
+    assert isinstance(XEP_0082.parse_date(XEP_0082.format_date()), date)
+
+
+def test_date_parsing() -> None:  # pylint: disable=missing-function-docstring
+    # There isn't really a point in testing much more than this
+    assert XEP_0082.parse_date("2003-04-21") == date(2003, 4, 21)
+
+
+def test_datetime_formatting() -> None:  # pylint: disable=missing-function-docstring
+    # The following tests don't check the output of format_datetime directly; instead,
+    # they assume that parse_datetime rejects incorrect formatting.
+
+    # A datetime in UTC
+    value = datetime(1234, 5, 6, 7, 8, 9, 123456, timezone.utc)
+
+    # Check that the parsed datetime equals the formatted datetime except for the
+    # microseconds
+    parsed = XEP_0082.parse_datetime(XEP_0082.format_datetime(value))
+    assert parsed != value
+    assert parsed.replace(microsecond=123456) == value
+
+    # Check that the parsed datetime equals the formatted datetime including microseconds
+    assert XEP_0082.parse_datetime(XEP_0082.format_datetime(value, True)) == value
+
+    # A datetime in some other timezone, from the Python docs of the datetime module
+    value = datetime.fromisoformat("2011-11-04T00:05:23+04:00")
+
+    # Check that the parsed datetime equals the formatted datetime with or without
+    # microseconds
+    assert XEP_0082.parse_datetime(XEP_0082.format_datetime(value)) == value
+    assert XEP_0082.parse_datetime(XEP_0082.format_datetime(value, True)) == value
+
+    # Check that the datetime was converted to UTC while formatting
+    assert XEP_0082.parse_datetime(XEP_0082.format_datetime(value)).tzinfo == timezone.utc
+
+    # Check that a datetime instance is returned when format_datetime is called without an
+    # explicit input
+    # datetime
+    assert isinstance(XEP_0082.parse_datetime(XEP_0082.format_datetime()), datetime)
+    assert isinstance(XEP_0082.parse_datetime(XEP_0082.format_datetime(
+        include_microsecond=True
+    )), datetime)
+    assert XEP_0082.parse_datetime(XEP_0082.format_datetime()).tzinfo == timezone.utc
+
+
+def test_datetime_parsing() -> None:  # pylint: disable=missing-function-docstring
+    # Datetime of the first human steps on the Moon (UTC)
+    value = datetime(1969, 7, 21, 2, 56, 15, tzinfo=timezone.utc)
+
+    # With timezone 'Z', without a fraction of a second
+    assert XEP_0082.parse_datetime("1969-07-21T02:56:15Z") == value
+
+    # With timezone '+04:00', without a fraction of a second
+    assert XEP_0082.parse_datetime("1969-07-21T06:56:15+04:00") == value
+
+    # With timezone '-05:00', without a fraction of a second
+    assert XEP_0082.parse_datetime("1969-07-20T21:56:15-05:00") == value
+
+    # Without timezone, without a fraction of a second
+    with pytest.raises(ValueError):
+        XEP_0082.parse_datetime("1969-07-21T02:56:15")
+
+    # With timezone 'Z', with a fraction of a second consisting of two digits
+    assert XEP_0082.parse_datetime("1969-07-21T02:56:15.12Z") == \
+        value.replace(microsecond=120000)
+
+    # With timezone 'Z', with a fraction of a second consisting of nine digits
+    assert XEP_0082.parse_datetime("1969-07-21T02:56:15.123456789Z") == \
+        value.replace(microsecond=123456)
+
+    # With timezone '+04:00', with a fraction of a second consisting of six digits
+    assert XEP_0082.parse_datetime("1969-07-21T06:56:15.123456+04:00") == \
+        value.replace(microsecond=123456)
+
+    # With timezone '-05:00', with a fraction of a second consisting of zero digits
+    assert XEP_0082.parse_datetime("1969-07-20T21:56:15.-05:00") == value
+
+    # Without timezone, with a fraction of a second consisting of six digits
+    with pytest.raises(ValueError):
+        XEP_0082.parse_datetime("1969-07-21T02:56:15.123456")
+
+
+def test_time_formatting() -> None:  # pylint: disable=missing-function-docstring
+    # The following tests don't check the output of format_time directly; instead, they
+    # assume that parse_time rejects incorrect formatting.
+
+    # A time in UTC
+    value = time(12, 34, 56, 789012, timezone.utc)
+
+    # Check that the parsed time equals the formatted time except for the microseconds
+    parsed = XEP_0082.parse_time(XEP_0082.format_time(value))
+    assert parsed != value
+    assert parsed.replace(microsecond=789012) == value
+
+    # Check that the parsed time equals the formatted time including microseconds
+    assert XEP_0082.parse_time(XEP_0082.format_time(value, True)) == value
+
+    # A time in some other timezone, from the Python docs of the datetime module
+    value = time.fromisoformat("04:23:01+04:00")
+
+    # Check that the parsed time equals the formatted time with or without microseconds
+    assert XEP_0082.parse_time(XEP_0082.format_time(value)) == value
+    assert XEP_0082.parse_time(XEP_0082.format_time(value, True)) == value
+
+    # Check that the time has retained its timezone
+    assert XEP_0082.parse_time(XEP_0082.format_time(value)).tzinfo == value.tzinfo
+
+    # A time without timezone information, from the Python docs of the datetime module
+    value = time.fromisoformat("04:23:01")
+
+    # Check that the parsed time doesn't have timezone information either
+    assert XEP_0082.parse_time(XEP_0082.format_time(value)).tzinfo is None
+
+    # Check that a time instance is returned when format_time is called without an
+    # explicit input date
+    assert isinstance(XEP_0082.parse_time(XEP_0082.format_time()), time)
+    assert isinstance(XEP_0082.parse_time(XEP_0082.format_time(
+        include_microsecond=True
+    )), time)
+    assert XEP_0082.parse_time(XEP_0082.format_time()).tzinfo == timezone.utc
+
+
+def test_time_parsing() -> None:  # pylint: disable=missing-function-docstring
+    # Time for tea
+    value = time(16, 0, 0, tzinfo=timezone.utc)
+
+    # With timezone 'Z', without a fraction of a second
+    assert XEP_0082.parse_time("16:00:00Z") == value
+
+    # With timezone '+04:00', without a fraction of a second
+    assert XEP_0082.parse_time("20:00:00+04:00") == value
+
+    # With timezone '-05:00', without a fraction of a second
+    assert XEP_0082.parse_time("11:00:00-05:00") == value
+
+    # Without a timezone, without a fraction of a second
+    assert XEP_0082.parse_time("16:00:00") == value.replace(tzinfo=None)
+
+    # With timezone 'Z', with a fraction of a second consisting of two digits
+    assert XEP_0082.parse_time("16:00:00.12Z") == value.replace(microsecond=120000)
+
+    # With timezone 'Z', with a fraction of a second consisting of nine digits
+    assert XEP_0082.parse_time("16:00:00.123456789Z") == value.replace(microsecond=123456)
+
+    # With timezone '+04:00', with a fraction of a second consisting of six digits
+    assert XEP_0082.parse_time("20:00:00.123456+04:00") == \
+        value.replace(microsecond=123456)
+
+    # With timezone '-05:00', with a fraction of a second consisting of zero digits
+    assert XEP_0082.parse_time("11:00:00.-05:00") == value
+
+    # Without a timezone, with a fraction of a second consisting of six digits
+    assert XEP_0082.parse_time("16:00:00.123456") == \
+        value.replace(microsecond=123456, tzinfo=None)
diff --git a/tests/unit/test_plugin_xep_0420.py b/tests/unit/test_plugin_xep_0420.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/test_plugin_xep_0420.py
@@ -0,0 +1,581 @@
+#!/usr/bin/env python3
+
+# Tests for Libervia's Stanza Content Encryption plugin
+# Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+# Type-check with `mypy --strict --disable-error-code no-untyped-call`
+# Lint with `pylint`
+
+from datetime import datetime, timezone
+from typing import Callable, Iterator, Optional, cast
+
+import pytest
+
+from sat.plugins.plugin_xep_0334 import NS_HINTS
+from sat.plugins.plugin_xep_0420 import (
+    NS_SCE, XEP_0420, AffixVerificationFailed, ProfileRequirementsNotMet, SCEAffixPolicy,
+    SCECustomAffix, SCEProfile
+)
+from sat.tools.xml_tools import ElementParser
+from twisted.words.xish import domish
+
+
+__all__ = [  # pylint: disable=unused-variable
+    "test_unpack_matches_original",
+    "test_affixes_included",
+    "test_all_affixes_verified",
+    "test_incomplete_affixes",
+    "test_rpad_affix",
+    "test_time_affix",
+    "test_to_affix",
+    "test_from_affix",
+    "test_custom_affixes",
+    "test_namespace_conversion",
+    "test_non_encryptable_elements",
+    "test_schema_validation"
+]
+
+
+string_to_domish = cast(Callable[[str], domish.Element], ElementParser())
+
+
+class CustomAffixImpl(SCECustomAffix):
+    """
+    A simple custom affix implementation for testing purposes. Verifies the full JIDs of
+    both sender and recipient.
+
+    @warning: This is just an example, an affix element like this might not make sense due
+        to potentially allowed modifications of recipient/sender full JIDs (I don't know
+        enough about XMPP routing to know whether full JIDs are always left untouched by
+        the server).
+    """
+
+    @property
+    def element_name(self) -> str:
+        return "full-jids"
+
+    @property
+    def element_schema(self) -> str:
+        return """<xs:element name="full-jids">
+            <xs:complexType>
+                <xs:attribute name="recipient" type="xs:string"/>
+                <xs:attribute name="sender" type="xs:string"/>
+            </xs:complexType>
+        </xs:element>"""
+
+    def create(self, stanza: domish.Element) -> domish.Element:
+        recipient = cast(Optional[str], stanza.getAttribute("to", None))
+        sender = cast(Optional[str], stanza.getAttribute("from", None))
+
+        if recipient is None or sender is None:
+            raise ValueError(
+                "Stanza doesn't have ``to`` and ``from`` attributes required by the"
+                " full-jids custom affix."
+            )
+
+        element = domish.Element((NS_SCE, "full-jids"))
+        element["recipient"] = recipient
+        element["sender"] = sender
+        return element
+
+    def verify(self, stanza: domish.Element, element: domish.Element) -> None:
+        recipient_target = element["recipient"]
+        recipient_actual = stanza.getAttribute("to")
+
+        sender_target = element["sender"]
+        sender_actual = stanza.getAttribute("from")
+
+        if recipient_actual != recipient_target or sender_actual != sender_target:
+            raise AffixVerificationFailed(
+                f"Full JIDs differ. Recipient: actual={recipient_actual} vs."
+                f" target={recipient_target}; Sender: actual={sender_actual} vs."
+                f" target={sender_target}"
+            )
+
+
+def test_unpack_matches_original() -> None:  # pylint: disable=missing-function-docstring
+    profile = SCEProfile(
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.OPTIONAL,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.OPTIONAL,
+        custom_policies={ CustomAffixImpl(): SCEAffixPolicy.NOT_NEEDED }
+    )
+
+    stanza_string = (
+        '<message from="foo@example.com" to="bar@example.com"><body>Test with both a body'
+        ' and some other custom element.</body><custom xmlns="urn:xmpp:example:0"'
+        ' test="matches-original">some more content</custom></message>'
+    )
+
+    stanza = string_to_domish(stanza_string)
+
+    envelope_serialized = XEP_0420.pack_stanza(profile, stanza)
+
+    # The stanza should not have child elements any more
+    assert len(list(stanza.elements())) == 0
+
+    XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
+
+    # domish.Element doesn't override __eq__, thus we compare the .toXml() strings here in
+    # the hope that serialization for an example as small as this is unique enough to be
+    # compared that way.
+    assert stanza.toXml() == string_to_domish(stanza_string).toXml()
+
+
+def test_affixes_included() -> None:  # pylint: disable=missing-function-docstring
+    custom_affix = CustomAffixImpl()
+
+    profile = SCEProfile(
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.OPTIONAL,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.OPTIONAL,
+        custom_policies={ custom_affix: SCEAffixPolicy.OPTIONAL }
+    )
+
+    stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
+        <body>
+            Make sure that both the REQUIRED and the OPTIONAL affixes are included.
+        </body>
+    </message>""")
+
+    affix_values = XEP_0420.unpack_stanza(
+        profile,
+        stanza,
+        XEP_0420.pack_stanza(profile, stanza)
+    )
+
+    assert affix_values.rpad is not None
+    assert affix_values.timestamp is not None
+    assert affix_values.recipient is None
+    assert affix_values.sender is not None
+    assert custom_affix in affix_values.custom
+
+
+def test_all_affixes_verified() -> None:  # pylint: disable=missing-function-docstring
+    packing_profile = SCEProfile(
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.REQUIRED,
+        custom_policies={}
+    )
+
+    unpacking_profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={}
+    )
+
+    stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
+        <body>
+            When unpacking, all affixes are loaded, even those marked as NOT_NEEDED.
+        </body>
+    </message>""")
+
+    envelope_serialized = XEP_0420.pack_stanza(packing_profile, stanza)
+
+    affix_values = XEP_0420.unpack_stanza(unpacking_profile, stanza, envelope_serialized)
+
+    assert affix_values.rpad is not None
+    assert affix_values.timestamp is not None
+    assert affix_values.recipient is not None
+    assert affix_values.sender is not None
+
+    # When unpacking, all affixes are verified, even if they are NOT_NEEDED by the profile
+    stanza = string_to_domish(
+        """<message from="fooo@example.com" to="baz@example.com"></message>"""
+    )
+
+    with pytest.raises(AffixVerificationFailed):
+        XEP_0420.unpack_stanza(unpacking_profile, stanza, envelope_serialized)
+
+
+def test_incomplete_affixes() -> None:  # pylint: disable=missing-function-docstring
+    packing_profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={}
+    )
+
+    unpacking_profile = SCEProfile(
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.REQUIRED,
+        custom_policies={}
+    )
+
+    stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
+        <body>Check that all affixes REQUIRED by the profile are present.</body>
+    </message>""")
+
+    with pytest.raises(ProfileRequirementsNotMet):
+        XEP_0420.unpack_stanza(
+            unpacking_profile,
+            stanza,
+            XEP_0420.pack_stanza(packing_profile, stanza)
+        )
+
+    # Do the same but with a custom affix missing
+    custom_affix = CustomAffixImpl()
+
+    packing_profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={ custom_affix: SCEAffixPolicy.NOT_NEEDED }
+    )
+
+    unpacking_profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={ custom_affix: SCEAffixPolicy.REQUIRED }
+    )
+
+    stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
+        <body>
+            Check that all affixes REQUIRED by the profile are present, including custom
+            affixes.
+        </body>
+    </message>""")
+
+    with pytest.raises(ProfileRequirementsNotMet):
+        XEP_0420.unpack_stanza(
+            unpacking_profile,
+            stanza,
+            XEP_0420.pack_stanza(packing_profile, stanza)
+        )
+
+
+def test_rpad_affix() -> None:  # pylint: disable=missing-function-docstring
+    profile = SCEProfile(
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={}
+    )
+
+    for _ in range(100):
+        stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
+            <body>OK</body>
+        </message>""")
+
+        affix_values = XEP_0420.unpack_stanza(
+            profile,
+            stanza,
+            XEP_0420.pack_stanza(profile, stanza)
+        )
+
+        # Test that the rpad exists and that the content elements are always padded to at
+        # least 53 characters
+        assert affix_values.rpad is not None
+        assert len(affix_values.rpad) >= 53 - len("<body xmlns='jabber:client'>OK</body>")
+
+
+def test_time_affix() -> None:  # pylint: disable=missing-function-docstring
+    profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={}
+    )
+
+    stanza = string_to_domish(
+        """<message from="foo@example.com" to="bar@example.com"></message>"""
+    )
+
+    envelope_serialized = f"""<envelope xmlns="{NS_SCE}">
+        <content>
+            <body xmlns="jabber:client">
+                The time affix is only parsed and not otherwise verified. Not much to test
+                here.
+            </body>
+        </content>
+        <time stamp="1969-07-21T02:56:15Z"/>
+    </envelope>""".encode("utf-8")
+
+    affix_values = XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
+    assert affix_values.timestamp == datetime(1969, 7, 21, 2, 56, 15, tzinfo=timezone.utc)
+
+
+def test_to_affix() -> None:  # pylint: disable=missing-function-docstring
+    profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.REQUIRED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={}
+    )
+
+    stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
+        <body>Check that the ``to`` affix is correctly added.</body>
+    </message>""")
+
+    envelope_serialized = XEP_0420.pack_stanza(profile, stanza)
+    affix_values = XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
+    assert affix_values.recipient is not None
+    assert affix_values.recipient.userhost() == "bar@example.com"
+
+    # Check that a mismatch in recipient bare JID causes an exception to be raised
+    stanza = string_to_domish(
+        """<message from="foo@example.com" to="baz@example.com"></message>"""
+    )
+
+    with pytest.raises(AffixVerificationFailed):
+        XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
+
+    # Check that only the bare JID matters
+    stanza = string_to_domish(
+        """<message from="foo@example.com" to="bar@example.com/device"></message>"""
+    )
+
+    affix_values = XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
+    assert affix_values.recipient is not None
+    assert affix_values.recipient.userhost() == "bar@example.com"
+
+    stanza = string_to_domish("""<message from="foo@example.com">
+        <body>
+            Check that a missing "to" attribute on the stanza fails stanza packing.
+        </body>
+    </message>""")
+
+    with pytest.raises(ValueError):
+        XEP_0420.pack_stanza(profile, stanza)
+
+
+def test_from_affix() -> None:  # pylint: disable=missing-function-docstring
+    profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.REQUIRED,
+        custom_policies={}
+    )
+
+    stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
+        <body>Check that the ``from`` affix is correctly added.</body>
+    </message>""")
+
+    envelope_serialized = XEP_0420.pack_stanza(profile, stanza)
+    affix_values = XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
+    assert affix_values.sender is not None
+    assert affix_values.sender.userhost() == "foo@example.com"
+
+    # Check that a mismatch in sender bare JID causes an exception to be raised
+    stanza = string_to_domish(
+        """<message from="fooo@example.com" to="bar@example.com"></message>"""
+    )
+
+    with pytest.raises(AffixVerificationFailed):
+        XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
+
+    # Check that only the bare JID matters
+    stanza = string_to_domish(
+        """<message from="foo@example.com/device" to="bar@example.com"></message>"""
+    )
+
+    affix_values = XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
+    assert affix_values.sender is not None
+    assert affix_values.sender.userhost() == "foo@example.com"
+
+    stanza = string_to_domish("""<message to="bar@example.com">
+        <body>
+            Check that a missing "from" attribute on the stanza fails stanza packing.
+        </body>
+    </message>""")
+
+    with pytest.raises(ValueError):
+        XEP_0420.pack_stanza(profile, stanza)
+
+
+def test_custom_affixes() -> None:  # pylint: disable=missing-function-docstring
+    custom_affix = CustomAffixImpl()
+
+    packing_profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={ custom_affix: SCEAffixPolicy.REQUIRED }
+    )
+
+    unpacking_profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={}
+    )
+
+    stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
+        <body>
+            If a custom affix is included in the envelope, but not excpected by the
+            recipient, the schema validation should fail.
+        </body>
+    </message>""")
+
+    with pytest.raises(ValueError):
+        XEP_0420.unpack_stanza(
+            unpacking_profile,
+            stanza,
+            XEP_0420.pack_stanza(packing_profile, stanza)
+        )
+
+    profile = packing_profile
+
+    stanza = string_to_domish("""<message from="foo@example.com/device0"
+        to="bar@example.com/Libervia.123">
+        <body>The affix element should be returned as part of the affix values.</body>
+    </message>""")
+
+    affix_values = XEP_0420.unpack_stanza(
+        profile,
+        stanza,
+        XEP_0420.pack_stanza(profile, stanza)
+    )
+
+    assert custom_affix in affix_values.custom
+    assert affix_values.custom[custom_affix].getAttribute("recipient") == \
+        "bar@example.com/Libervia.123"
+    assert affix_values.custom[custom_affix].getAttribute("sender") == \
+        "foo@example.com/device0"
+
+
+def test_namespace_conversion() -> None:  # pylint: disable=missing-function-docstring
+    profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={}
+    )
+
+    stanza = domish.Element((None, "message"))
+    stanza["from"] = "foo@example.com"
+    stanza["to"] = "bar@example.com"
+    stanza.addElement(
+        "body",
+        content=(
+            "This body element has namespace ``None``, which has to be replaced with"
+            " jabber:client."
+        )
+    )
+
+    envelope_serialized = XEP_0420.pack_stanza(profile, stanza)
+    envelope = string_to_domish(envelope_serialized.decode("utf-8"))
+    content = next(cast(Iterator[domish.Element], envelope.elements(NS_SCE, "content")))
+
+    # The body should have been assigned ``jabber:client`` as its namespace
+    assert next(
+        cast(Iterator[domish.Element], content.elements("jabber:client", "body")),
+        None
+    ) is not None
+
+    XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
+
+    # The body should still have ``jabber:client`` after unpacking
+    assert next(
+        cast(Iterator[domish.Element], stanza.elements("jabber:client", "body")),
+        None
+    ) is not None
+
+
+def test_non_encryptable_elements() -> None:  # pylint: disable=missing-function-docstring
+    profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={}
+    )
+
+    stanza = string_to_domish("""<message from="foo@example.com" to="bar@example.com">
+        <body>This stanza includes a store hint which must not be encrypted.</body>
+        <store xmlns="urn:xmpp:hints"/>
+    </message>""")
+
+    envelope_serialized = XEP_0420.pack_stanza(profile, stanza)
+    envelope = string_to_domish(envelope_serialized.decode("utf-8"))
+    content = next(cast(Iterator[domish.Element], envelope.elements(NS_SCE, "content")))
+
+    # The store hint must not have been moved to the content element
+    assert next(
+        cast(Iterator[domish.Element], stanza.elements(NS_HINTS, "store")),
+        None
+    ) is not None
+
+    assert next(
+        cast(Iterator[domish.Element], content.elements(NS_HINTS, "store")),
+        None
+    ) is None
+
+    stanza = string_to_domish(
+        """<message from="foo@example.com" to="bar@example.com"></message>"""
+    )
+
+    envelope_serialized = f"""<envelope xmlns="{NS_SCE}">
+        <content>
+            <body xmlns="jabber:client">
+                The store hint must not be moved to the stanza.
+            </body>
+            <store xmlns="urn:xmpp:hints"/>
+        </content>
+    </envelope>""".encode("utf-8")
+
+    XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)
+
+    assert next(
+        cast(Iterator[domish.Element], stanza.elements(NS_HINTS, "store")),
+        None
+    ) is None
+
+
+def test_schema_validation() -> None:  # pylint: disable=missing-function-docstring
+    profile = SCEProfile(
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        SCEAffixPolicy.NOT_NEEDED,
+        custom_policies={}
+    )
+
+    stanza = string_to_domish(
+        """<message from="foo@example.com" to="bar@example.com"></message>"""
+    )
+
+    envelope_serialized = f"""<envelope xmlns="{NS_SCE}">
+        <content>
+            <body xmlns="jabber:client">
+                An unknwon affix should cause a schema validation error.
+            </body>
+            <store xmlns="urn:xmpp:hints"/>
+        </content>
+        <unknown-affix unknown-attr="unknown"/>
+    </envelope>""".encode("utf-8")
+
+    with pytest.raises(ValueError):
+        XEP_0420.unpack_stanza(profile, stanza, envelope_serialized)