. "$BST_LIB" # BST-TIMEOUT: 1200 # THREE python3 starts, and they are the whole cost. # # # They are: a bare `import itsdangerous', a second whose # # STDERR is the assertion (`import-quiet'), and the probe, # # which takes ONE start for 86 measurements. Every other # # case below reads the filesystem or dpkg with shell # # builtins, or greps the probe's output. # # # There is no fourth for a shipped executable: this package # # declares no `scripts' and no entry points, so nothing # # lands in /usr/bin. `no-bin-script' asserts that absence # # rather than leaving it unstated. # # # THE PROBE'S OWN WORK IS NEGLIGIBLE -- a few dozen # # HMAC-SHA1 computations over strings of tens of bytes, plus # # four json round trips. There is no KDF here and no cost # # parameter to pin: itsdangerous signs, it does not hash # # passwords. The whole probe took 0.03s on an x86-64 # # control, so this budget is three interpreter starts and # # essentially nothing else. # # # 1200 is 400s per start, matching 99-python3-passlib's # # budget for its own three and roughly six times the ~60s a # # python3 start has measured at on this arm. # # # A suite-timeout red is a batch-wide cost, so if the three # # starts prove slower than that on target the RIGHT fix is # # this number, not fewer cases. # # # NOT MEASURED ON TARGET YET: this port's first on-target # # run is the batch verify, and the measured elapsed replaces # # this note in the test plan rather than in this comment, # # which would cost an image build to keep the shipped suite # # byte-identical. # # python3-itsdangerous 2.1.2 (meta-openembedded/meta-python # recipes-devtools/python/python3-itsdangerous_2.1.2.bb -- `inherit pypi # setuptools3', PyPI sdist, no patches) on the wasm32-hwjs glibc image. # # THE VERSION IS 2.1.2, THE ONE BRINTOS-2408 NAMES. It is worth stating because # an earlier pass of this port asserted 2.2.0 throughout, from reading upstream # rather than the layer: meta-openembedded at this pin carries 2.1.2, and the # port follows the layer. Everything version-dependent below is now taken from # the built .deb -- `legacy-version-warns' is `no' because 2.1.2 does NOT # deprecate `__version__', and the wheel generator is setuptools' bdist_wheel, # not flit. # # ============================================================================ # WHAT THE PACKAGE DOES. # ============================================================================ # # IT SIGNS DATA SO IT CAN BE HANDED TO SOMEBODY YOU DO NOT TRUST AND TAKEN BACK # UNCHANGED. A session cookie, a password-reset link, an unsubscribe URL: the # value travels through the user's own browser, and what comes back must be # provably the value that left. `Signer' appends an HMAC over the payload; # `TimestampSigner' folds a signing time INTO the signed region so the token can # also be expired; the `Serializer' family does the same for arbitrary # JSON-serialisable objects, and the `URLSafe*' pair encodes the result so it # survives a query string. It is the signing half of Flask's session cookie. # # IT DOES NOT ENCRYPT, AND THAT IS THE POINT WORTH STATING TWICE. The payload # travels in the clear -- base64 is not a cipher -- and this suite asserts that # out loud in two places rather than leaving a reader to infer it: # `token-payload-cleartext' shows the raw payload sitting in the token, and # `us-payload-readable' recovers the whole serialised object from a URL-safe # token WITHOUT the secret key. Believing otherwise is the single most common # misuse of this package, and a suite that only ever asserted "it round-trips" # would leave that belief undisturbed. # # ============================================================================ # WHY IT PORTS, AND WHAT IT WAITS ON: NOTHING. # ============================================================================ # # PURE PYTHON, AND UNUSUALLY SMALL. This image's python3 is ONE STATIC BINARY # with an empty lib-dynload and no dynamic loader (BRINTOS-1476), so a module # carrying a compiled extension installs cleanly and cannot be imported. # itsdangerous declares no ext_modules and the built .deb ships no `.so' at all # -- `pkg-no-so' asserts that off the shipped file list rather than off the # source -- so this port carries no red attributable to BRINTOS-2046. The wheel # says the same thing in its own words: `Root-Is-Purelib: true' and the # `py3-none-any' tag. # # THE WHOLE PACKAGE IS SEVEN MODULES: __init__, signer, timed, serializer, # url_safe, encoding, exc -- plus a PEP 561 `py.typed' marker, which is the only # non-Python file it ships and which `pkg-only-python' allows by name. # # EVERY RUNTIME DEPENDENCY IS ALREADY ON THIS IMAGE. meta-python's recipe # declares `python3-simplejson python3-netclient python3-compression # python3-json'. Three of those are splits of the ported interpreter; the fourth, # python3-simplejson, is a separate PyPI package that this image ALREADY carries # for other ports (it is in packagegroup-brintos-extra ahead of this change), so # this port adds no new package to the image but its own and needs no bbappend. # Each is asserted by name below: a bare "the Depends line exists" would pass on # a stanza that had lost one. # # THAT DEPENDENCY SET IS WIDER THAN THE CODE ACTUALLY USES, and it is worth # saying so rather than letting a reader conclude the port drags in a JSON stack. # itsdangerous 2.1.2 imports `json' from the standard library in `_json.py' and # nothing else; it does not import simplejson, does not open a socket, and does # not compress anything outside `url_safe.py's zlib call. The extra names are # meta-python's over-declaration, they are harmless because every one of them is # already present, and this port does not narrow them -- editing another layer's # recipe to tighten a dependency it does not own is not this ticket's work. # # ============================================================================ # TIME IS AN INPUT HERE, NEVER AN AMBIENT FACT. # ============================================================================ # # The timed signers read the clock in exactly ONE place -- # `TimestampSigner.get_timestamp()', which both `sign' and `unsign' call -- so # the probe subclasses it and pins that method to a constant (T0 = # 1700000000). That makes the signing time AND the verification time inputs, and # it is what lets `ts-token' below be asserted as a LITERAL: the same bytes on # any host, in any timezone, on any date, whenever the batch happens to run. # # A suite that called the real clock would have a token whose literal changed # every second, a `max-age' case whose answer depended on how long the boot # queued, and a boundary case that could not be written at all. It would also be # the kind of test that passes for months and then reds once, at midnight, in a # timezone nobody was watching. # # `ts-expired-date-signed' is reduced to a POSIX integer inside the probe for # the same reason: `SignatureExpired.date_signed' is a datetime, and rendering # one depends on the guest's timezone database, which is not what this port is # measuring. # # ============================================================================ # WHAT IS BEING ASSERTED, AND AGAINST WHAT. # ============================================================================ # # THREE INDEPENDENT KINDS OF EVIDENCE, because "itsdangerous agrees with # itsdangerous" is not a measurement: # # 1. LITERAL TOKENS. `sign-token', `ts-token', `us-token' and `uts-token' assert # the EXACT produced string. This is the assertion the package's whole purpose # demands: a round trip is satisfied by any self-consistent implementation, # including one whose base64 alphabet, key derivation or timestamp epoch # differs from every other host's -- and such an implementation would sign # tokens that this arm accepts and nothing else in the world does. Every # literal was measured on an x86-64 python3.12 control against the SAME 2.1.2 # wheel this recipe installs, before it was written here. # # 2. AN INDEPENDENT RECOMPUTATION IN-PROCESS. `sig-vs-hmac' rebuilds the # signature straight from the documented algorithm using hmac and hashlib -- # derive the key as sha1(salt + b"signer" + secret), HMAC-SHA1 the payload, # urlsafe-base64 it and strip the padding -- and compares it to what the # Signer produced. That shares no code with itsdangerous's signer, and it is # what turns "the literal matches" into "the literal matches for the # documented reason". `derive-key-vs-lib' splits the same claim in two, so a # key-derivation change is distinguishable from an HMAC change. # # 3. THE NEGATIVE CASES, which are where a signing library actually earns its # keep. A flipped payload character, a flipped signature character, a token # signed under a DIFFERENT SALT, one signed under a different SECRET, a # timestamp moved forward to defeat expiry, and two malformed strings -- each # must be refused, and refused with the documented exception type. Every # positive case above would still pass on an implementation that accepted all # of them. # # ============================================================================ # THE FOUR FINDINGS, ALL UPSTREAM'S, AND NONE OF THEM THIS ARM'S. # ============================================================================ # # Each was MEASURED on an x86-64 python3.12 control against the SAME wheel this # image installs, before it was written down here. None is filed: they are # properties of itsdangerous 2.1.2 on every platform, and this port neither # introduces nor can fix them. See docs/conventions/no-public-upstream-reports.md. # # 1. `__version__' IS A PLAIN ATTRIBUTE IN 2.1.2, AND DOES NOT WARN. It is a # module-level string, with no `__getattr__' shim behind it, so reading it # answers "2.1.2" silently -- `legacy-version-warns' is `no', measured, not # assumed. Upstream deprecates it in 2.2.0 and removes it in 2.3, so the # suite still takes its own version case from `importlib.metadata' (the # source that survives that removal) and pins the attribute's CURRENT # behaviour separately. When this recipe is next advanced, those two cases # are where the change becomes visible instead of silent. # # 2. THE PAYLOAD IS NOT CONFIDENTIAL. Covered at length above; asserted by # `token-payload-cleartext' and `us-payload-readable'. # # 3. `max_age' IS COMPARED WITH `age > max_age', SO THE BOUNDARY IS INCLUSIVE. # A token exactly `max_age' seconds old still verifies; one a second older # does not. `ts-boundary-exact-ok' and `ts-boundary-past-exc' assert both # sides on purpose -- an off-by-one here is invisible to any case that does # not sit exactly on the edge, and "expired" is a security boundary rather # than a cosmetic one. # # 4. THE TWO TAMPERING CASES RAISE DIFFERENT TYPES, and the difference is # meaningful. A flipped byte anywhere in a plain `Signer' token is # `BadSignature'; moving the TIMESTAMP field of a `TimestampSigner' token is # `BadTimeSignature', a subclass carrying the timestamp it managed to read. # `exc-*' below asserts the hierarchy that makes `except BadSignature' catch # both -- which is the documented way to reject a token, and only works # because of that inheritance. # # ============================================================================ # WHAT IS DELIBERATELY OUT OF SCOPE. # ============================================================================ # # `Serializer' with a NON-JSON serialiser (pickle, or a caller's own), and the # `fallback_signers' key-rotation path. The first is a caller's choice of # module rather than a property of this port -- and pickle over untrusted data is # the misuse this package exists to prevent. The second needs a multi-key # fixture that says nothing about whether the port works on this arm. Neither is # what this ticket ports the package for. P=python3-itsdangerous # `suite-canary' is emitted FIRST and unconditionally. A suite that dies before # its first result line is indistinguishable from one that was never scheduled, # so this one line is what separates "ran and found nothing" from "did not run". bst_ok $P/suite-canary require python3 $P/python3-present SITE=/usr/lib/python3.12/site-packages PKG=$SITE/itsdangerous DI=$SITE/itsdangerous-2.1.2.dist-info PKGLIST=/var/lib/dpkg/info/python3-itsdangerous.list PROBE=$BST_DATA/python3-itsdangerous-probe.py tmp=${TMPDIR:-/tmp} W=$tmp/bst-itsdangerous.$$ mkdir -p "$W" || { bst_notok $P/workdir "cannot create $W"; exit 0; } # ---------------------------------------------------------------- packaging -- # WHERE THE PACKAGING SAYS THE MODULE LANDS. These run before any import, so a # red here says the .deb is wrong rather than that the module misbehaves. check $P/site-packages-dir test -d "$SITE" check $P/package-dir test -d "$PKG" check $P/package-init test -s "$PKG/__init__.py" check $P/no-flat-module test ! -f "$SITE/itsdangerous.py" # THE SEVEN MODULES, EACH BY NAME. This package is small enough to assert WHOLE # rather than sampled, so a release that split or dropped one is visible here # rather than as an ImportError inside the probe. check $P/module-signer test -s "$PKG/signer.py" check $P/module-timed test -s "$PKG/timed.py" check $P/module-serializer test -s "$PKG/serializer.py" check $P/module-url-safe test -s "$PKG/url_safe.py" check $P/module-encoding test -s "$PKG/encoding.py" check $P/module-exc test -s "$PKG/exc.py" check $P/module-json test -s "$PKG/_json.py" # THE PEP 561 MARKER. itsdangerous ships inline type annotations and the marker # that tells a type checker to trust them. It is the ONLY non-Python file in the # package, which is why `pkg-only-python' below can be as strict as it is. check $P/py-typed test -f "$PKG/py.typed" # NO EXECUTABLE. The package declares no scripts and no entry points, so nothing # lands in /usr/bin. Asserted so that a future release which started shipping one # is noticed here. check $P/no-bin-script test ! -e /usr/bin/itsdangerous # --------------------------------------------------------------- dist-info --- # The version is read from METADATA rather than from the module, so a mismatch # between what the packaging claims and what imports is visible as a mismatch. # `dist-version' below reads importlib.metadata for the other half. check $P/dist-info-dir test -d "$DI" check $P/dist-info-metadata test -f "$DI/METADATA" check_out $P/dist-info-version "Version: 2.1.2" cat "$DI/METADATA" check_out $P/dist-info-name "Name: itsdangerous" cat "$DI/METADATA" check $P/dist-info-wheel test -f "$DI/WHEEL" check $P/dist-info-record test -f "$DI/RECORD" check $P/dist-info-license test -f "$DI/LICENSE.rst" # PURE PYTHON, stated by the packaging itself. `Root-Is-Purelib: true' is the # wheel's own claim that nothing in it is architecture-specific, which is the # property that makes this package portable to this arm at all; the `py3-none-any' # tag is the same claim in the filename's coordinates. check_out $P/dist-info-purelib "Root-Is-Purelib: true" cat "$DI/WHEEL" check_out $P/dist-info-tag "Tag: py3-none-any" cat "$DI/WHEEL" # THE BUILD BACKEND IS setuptools -- which is what `inherit setuptools3' in the # recipe means, and the reason a `top_level.txt' is present to assert at all. # (2.2.0 moves to flit and drops it; asserting it here is what will make that # change visible when the recipe is advanced, rather than silently absent.) check_out $P/dist-info-generator "bdist_wheel" cat "$DI/WHEEL" check $P/dist-info-toplevel test -f "$DI/top_level.txt" check_out $P/dist-info-toplevel-name "itsdangerous" cat "$DI/top_level.txt" # ------------------------------------------------------------ the .deb file --- # The installed file list, read once into $W. The copy is what the loop below # redirects from: a stdin redirect the top-level shell performs on the virtiofs # root makes dup2(2) return 513 and ENDS the suite silently (brintos/linux#73), # and $W is on tmpfs. cat "$PKGLIST" > "$W/pkgf" 2>/dev/null || : > "$W/pkgf" check $P/pkg-list-present test -s "$W/pkgf" # NO COMPILED EXTENSION, asserted off the shipped file list rather than off the # source. This image's python3 has an empty lib-dynload and no dynamic loader # (BRINTOS-1476), so a single .so here would mean a package that installs # cleanly and cannot import -- the BRINTOS-2046 shape. check_eq $P/pkg-no-so "" \ "$(grep -c '\.so$' "$W/pkgf" 2>/dev/null | grep -v '^0$')" # Every payload path the package ships is under site-packages, and is a .py, a # .pyc, the py.typed marker, or dist-info metadata. Anything else is a file this # port did not expect to be shipping and wants looking at. _bad="" while IFS= read -r _f; do case "$_f" in ''|/usr/lib/python3.12/site-packages) continue ;; */) continue ;; esac [ -d "$_f" ] && continue case "$_f" in *.py|*.pyc) ;; # The PEP 561 marker: the package's only non-Python payload. */py.typed) ;; # dist-info metadata. Which of these the build backend writes is a property # of FLIT rather than of this package, so the whole standard set is allowed: # pinning the exact list would red on a backend change that says nothing # about itsdangerous. `pkg-no-so' above carries the real claim. */METADATA|*/WHEEL|*/RECORD|*/INSTALLER|*/LICENSE*|*/entry_points.txt|*/top_level.txt|*/zip-safe) ;; *) _bad="$_bad $_f" ;; esac done < "$W/pkgf" check_eq $P/pkg-only-python "" "$_bad" # ------------------------------------------------------------- dpkg status --- sed -n '/^Package: python3-itsdangerous$/,/^$/p' /var/lib/dpkg/status > "$W/st" 2>/dev/null check $P/pkg-known test -s "$W/st" check_out $P/pkg-installed "Status: install ok installed" cat "$W/st" check_out $P/pkg-version "Version: 2.1.2-r0" cat "$W/st" check_out $P/pkg-arch "Architecture: wasm32" cat "$W/st" # THE DEPENDENCY SET IS THE PORT'S CLAIM THAT IT WAITS ON NOTHING. Three of # these are splits of the ported python3 (BRINTOS-1476) and the fourth, # python3-simplejson, is a package this image already carried before this port. # Each is asserted individually -- see the header on why this set is wider than # the code actually uses. grep '^Depends:' "$W/st" > "$W/dep" 2>/dev/null || : > "$W/dep" check $P/pkg-depends-line test -s "$W/dep" for _d in python3-core python3-json python3-netclient python3-compression python3-simplejson; do check_out $P/pkg-depends-$_d "$_d" cat "$W/dep" done # ------------------------------------------------------------------ import --- # THE FIRST OF THREE PYTHON STARTS. If the Depends set above were short by one # sub-package, this is the case that would say so. check $P/import python3 -c 'import itsdangerous' # THE SECOND. A package that imports but WARNS on the way in is a different # defect from one that fails, and it is invisible to the case above because the # exit status is 0 either way. The assertion is that stderr is EMPTY. # # It is not a formality here: this package is the signing half of a web stack and # is imported on paths where stderr is not read, so a warning emitted at import # would go unseen indefinitely. 2.1.2 emits none -- asserted, not assumed -- and # `legacy-version-warns' below pins the matching fact about attribute access. # From 2.2.0 upstream deprecates `__version__' behind a module `__getattr__', # which fires on ACCESS rather than at import; these two cases are what will # tell those apart when the recipe is advanced. check_eq $P/import-quiet "" \ "$(python3 -c 'import itsdangerous' 2>&1 >/dev/null)" # ------------------------------------------------------------------- probe --- if [ ! -f "$PROBE" ]; then bst_notok $P/probe-present "probe missing at $PROBE" rm -rf "$W" exit 0 fi bst_ok $P/probe-present # THE THIRD START, and the one that carries the suite. Bounded at 900s inside # the 1200s suite budget so that a hung probe is reported as a probe overrun # with the last line it printed, rather than as a suite timeout that says # nothing about where it stopped. bst_probe $P/probe-run "$W/probe" 900 python3 "$PROBE" OUT="$W/probe" # pv : the value the probe emitted for , or empty. `head -n 1' # because a key is emitted once and a duplicate would otherwise concatenate; # `cut -f2-' because several values legitimately contain `='. pv() { grep "^$1=" "$OUT" 2>/dev/null | head -n 1 | cut -d= -f2-; } # The probe reached its end. Every case below reads a value the probe printed, # so a probe that stopped early would otherwise turn into a run of empty-string # mismatches that all name the wrong thing. check_eq $P/probe-complete "yes" "$(pv probe_done)" check_eq $P/probe-count "86" "$(pv emitted_count)" # ---------------------------------------------------------------- identity --- # WHICH COPY ANSWERED. Without this every behavioural case below would be a # statement about whatever was first on sys.path. check_eq $P/module-answered "$PKG/__init__.py" "$(pv module_file)" check_eq $P/is-package "yes" "$(pv is_package)" check_eq $P/dist-version "2.1.2" "$(pv version)" # FINDING 1. `__version__' ANSWERS, and does NOT warn -- in 2.1.2 it is a plain # module attribute. Both halves are asserted: upstream deprecates it in 2.2.0 # and removes it in 2.3, so a release that started warning, or dropped it, is a # different package from the one this port measured. check_eq $P/legacy-version-attr "2.1.2" "$(pv legacy_version_attr)" check_eq $P/legacy-version-warns "no" "$(pv legacy_version_warns)" # The public API this suite reaches through, asserted by name so a release that # moved one says WHICH. for _n in signer timestampsigner serializer urlsafeserializer \ urlsafetimedserializer badsignature signatureexpired; do check_eq $P/has-$_n "yes" "$(pv has_$_n)" done # THE SIGNER'S DEFAULTS, which every literal below is a function of. A release # that changed the default digest or key derivation would change every token in # this suite, and this is the case that would say so in one line rather than as # eight literal mismatches. check_eq $P/defaults-digest "sha1" "$(pv defaults_digest)" check_eq $P/defaults-key-derivation "django-concat" "$(pv defaults_key_derivation)" check_eq $P/defaults-sep "." "$(pv defaults_sep)" # ------------------------------------------------------- Signer: the literal -- # EVIDENCE 1. The exact token, from a fixed key, a fixed salt and a fixed # payload. Measured on an x86-64 python3.12 control against this same wheel. check_eq $P/sign-token \ "BRINTOS-2408-payload.yYCkXf_9pdDs9Q9sbnUvnfhJ8Tw" "$(pv sign_token)" check_eq $P/sign-signature "yYCkXf_9pdDs9Q9sbnUvnfhJ8Tw" "$(pv sign_signature)" check_eq $P/token-is-ascii "yes" "$(pv token_is_ascii)" # FINDING 2. The payload sits in the token IN THE CLEAR. itsdangerous signs, it # does not encrypt. check_eq $P/token-parts "2" "$(pv token_parts)" check_eq $P/token-payload-cleartext "yes" "$(pv token_payload_cleartext)" # THE ROUND TRIP. check_eq $P/unsign "BRINTOS-2408-payload" "$(pv unsign)" check_eq $P/unsign-matches "yes" "$(pv unsign_matches)" check_eq $P/validate-good "yes" "$(pv validate_good)" # EVIDENCE 2. The signature recomputed from the documented algorithm with hmac # and hashlib, sharing no code with the signer. The derived key is asserted # separately so a key-derivation change is distinguishable from an HMAC change. check_eq $P/derived-key-hex \ "b49c7a26e6dfffe8382afa367f53eb1a1bb5bb5e" "$(pv derived_key_hex)" check_eq $P/derive-key-vs-lib "yes" "$(pv derive_key_vs_lib)" check_eq $P/sig-independent \ "yYCkXf_9pdDs9Q9sbnUvnfhJ8Tw" "$(pv sig_independent)" check_eq $P/sig-vs-hmac "yes" "$(pv sig_vs_hmac)" # NO RANDOMNESS. There is no nonce and no per-call salt, which is what makes # every literal in this suite assertable at all. check_eq $P/sign-deterministic "yes" "$(pv sign_deterministic)" # --------------------------------------------- Signer: the negative cases ---- # EVIDENCE 3. One character changed, in each of the three places it can be # changed. Each must be REFUSED -- every positive case above would pass on an # implementation that accepted all of them. check_eq $P/tampered-payload-exc "BadSignature" "$(pv tampered_payload_exc)" check_eq $P/tampered-payload-validate "no" "$(pv tampered_payload_validate)" check_eq $P/tampered-sig-exc "BadSignature" "$(pv tampered_sig_exc)" check_eq $P/tampered-sig-validate "no" "$(pv tampered_sig_validate)" # A DIFFERENT SALT IS A DIFFERENT KEY. The salt is not a namespace label the # signer merely records -- it goes into the key derivation -- so a token signed # for one purpose must not verify for another. This is the property that lets one # secret key serve several independent token kinds, and it is asserted as a # literal too: the two tokens carry the SAME cleartext payload and different # signatures. check_eq $P/other-salt-token \ "BRINTOS-2408-payload._PJWS0pzgpI0yhd_0_ku07cgqsE" "$(pv other_salt_token)" check_eq $P/other-salt-differs "yes" "$(pv other_salt_differs)" check_eq $P/other-salt-exc "BadSignature" "$(pv other_salt_exc)" check_eq $P/other-salt-validate "no" "$(pv other_salt_validate)" # ...and it verifies under its OWN salt, so the case above is about the salt and # not about a token that was broken to begin with. check_eq $P/other-salt-own-ok "yes" "$(pv other_salt_own_ok)" # A DIFFERENT SECRET KEY, likewise refused. check_eq $P/other-key-exc "BadSignature" "$(pv other_key_exc)" # MALFORMED INPUT IS A BadSignature, NOT A CRASH. A caller wrapping `unsign' in # `except BadSignature' must not be reachable by an IndexError from a token with # no separator, or from the empty string. check_eq $P/no-sep-exc "BadSignature" "$(pv no_sep_exc)" check_eq $P/empty-exc "BadSignature" "$(pv empty_exc)" # ------------------------------------------ TimestampSigner, pinned clock ---- # THE CLOCK IS AN INPUT. See the header: the probe overrides `get_timestamp', # which is the one place itsdangerous reads it, so every literal here is stable # on any host in any timezone on any date. check_eq $P/timestamp-epoch-pinned "1700000000" "$(pv timestamp_epoch_pinned)" check_eq $P/ts-token \ "BRINTOS-2408-payload.ZVPxAA.t2LwFa9UsDh_Ou-akCB1IUFfVyc" "$(pv ts_token)" check_eq $P/ts-token-is-ascii "yes" "$(pv ts_token_is_ascii)" # THREE PARTS NOW, and the payload is STILL in the clear. check_eq $P/ts-token-parts "3" "$(pv ts_token_parts)" check_eq $P/ts-payload-cleartext "yes" "$(pv ts_payload_cleartext)" # THE TIMESTAMP FIELD, AND WHAT IT DECODES TO. itsdangerous 2.x encodes # int(time) big-endian, urlsafe-base64 without padding, on the UNIX EPOCH -- # 1.x used a 2011 epoch. A port that silently changed which epoch is in use # would round-trip perfectly against itself while rejecting every token the rest # of the world signed, and no round-trip case could see it. Decoded in the probe # independently of the library. check_eq $P/ts-timestamp-field "ZVPxAA" "$(pv ts_timestamp_field)" check_eq $P/ts-timestamp-decoded "1700000000" "$(pv ts_timestamp_decoded)" # INSIDE max_age: verifies, and hands back the payload unchanged. check_eq $P/ts-fresh-unsign "BRINTOS-2408-payload" "$(pv ts_fresh_unsign)" check_eq $P/ts-fresh-ok "yes" "$(pv ts_fresh_ok)" # FINDING 3. THE BOUNDARY IS INCLUSIVE: max_age is compared with `age > max_age', # so a token exactly max_age seconds old is still fresh and one a second older is # not. Both sides are asserted on purpose -- an off-by-one here is invisible to # any case that does not sit exactly on the edge, and "expired" is a security # boundary rather than a cosmetic one. check_eq $P/ts-boundary-exact-ok "yes" "$(pv ts_boundary_exact_ok)" check_eq $P/ts-boundary-past-exc "SignatureExpired" "$(pv ts_boundary_past_exc)" # OUTSIDE max_age: SignatureExpired, and it CARRIES THE RECOVERED TIMESTAMP -- # the field a caller reads to tell a user how stale their link is. "It raised" is # not the whole assertion. check_eq $P/ts-expired-exc "SignatureExpired" "$(pv ts_expired_exc)" check_eq $P/ts-expired-date-signed "1700000000" "$(pv ts_expired_date_signed)" check_eq $P/ts-expired-is-badsignature "yes" "$(pv ts_expired_is_badsignature)" # WITHOUT max_age the age is not consulted at all: an ancient token still # verifies. This is what makes expiry the CALLER's decision rather than the # signer's, and a port that enforced a default would break every caller that # relies on it. check_eq $P/ts-no-maxage-ok "yes" "$(pv ts_no_maxage_ok)" # FINDING 4. A TAMPERED TIMESTAMP IS REFUSED, and with the more specific type. # The timestamp is INSIDE the signed region, so moving it forward to defeat # expiry must not verify -- which is the whole reason it is signed rather than # merely appended. `BadTimeSignature' rather than `BadSignature': a subclass, so # `except BadSignature' still catches it, which `exc-badtime-is-badsig' asserts. check_eq $P/ts-moved-token-differs "yes" "$(pv ts_moved_token_differs)" check_eq $P/ts-moved-exc "BadTimeSignature" "$(pv ts_moved_exc)" # ------------------------------------------------------------ Serializer ----- # A NESTED STRUCTURE -- dict inside dict, a list, a bool, a null, a non-ASCII # string and a large int -- so the round trip is a statement about the serialiser # and not about a scalar. The json form is asserted first, with sorted keys, so # that a red in the token literals below can be told apart from a change in how # this image's json module renders the SAME object. # The non-ASCII char is asserted ESCAPED: json.dumps defaults to # ensure_ascii=True, so the probe emits caf\u00e9, not a raw UTF-8 e-acute. check_eq $P/obj-json \ '{"nested":{"flag":true,"list":[1,2,3],"none":null},"number":1234567890,"ticket":"BRINTOS-2408","unicode":"caf\u00e9"}' \ "$(pv obj_json)" check_eq $P/ser-token-type "str" "$(pv ser_token_type)" check_eq $P/ser-roundtrip "yes" "$(pv ser_roundtrip)" check_eq $P/ser-tampered-exc "BadSignature" "$(pv ser_tampered_exc)" # ------------------------------------------------------ URLSafeSerializer ---- check_eq $P/us-token \ "eyJ0aWNrZXQiOiJCUklOVE9TLTI0MDgiLCJuZXN0ZWQiOnsibGlzdCI6WzEsMiwzXSwiZmxhZyI6dHJ1ZSwibm9uZSI6bnVsbH0sInVuaWNvZGUiOiJjYWbDqSIsIm51bWJlciI6MTIzNDU2Nzg5MH0.ozcapl55EtQr_DqgS8DhXGoyvzE" \ "$(pv us_token)" check_eq $P/us-token-type "str" "$(pv us_token_type)" check_eq $P/us-roundtrip "yes" "$(pv us_roundtrip)" # URL-SAFE BY INSPECTION, NOT BY ASSUMPTION. The claim in the class's NAME is # that the token can go in a query string or a path segment without escaping, so # the character set is checked against RFC 3986's unreserved set plus the `.' # separator. `us-offending-chars' carries the offenders by name, so a red here # says WHICH character rather than merely that there was one. check_eq $P/us-urlsafe "yes" "$(pv us_urlsafe)" check_eq $P/us-offending-chars "" "$(pv us_offending_chars)" # The two characters standard base64 would have used, and the padding, each # asserted absent by name. check_eq $P/us-no-plus "yes" "$(pv us_no_plus)" check_eq $P/us-no-slash "yes" "$(pv us_no_slash)" check_eq $P/us-no-equals "yes" "$(pv us_no_equals)" # THE COMPRESSION FLAG, MEASURED RATHER THAN PREDICTED. URLSafeSerializer # zlib-compresses the payload when that makes it shorter and marks it with a # LEADING `.'. For THIS payload it does not -- 116 bytes of json with little # repetition -- and that is a property of the fixture, asserted so that a change # in either direction is visible rather than silently altering the literal above. check_eq $P/us-compressed "no" "$(pv us_compressed)" # FINDING 2, restated where it is least obvious: a base64 blob LOOKS opaque. The # whole object is recovered from the token WITHOUT the secret key. check_eq $P/us-payload-readable "yes" "$(pv us_payload_readable)" check_eq $P/us-tampered-exc "BadSignature" "$(pv us_tampered_exc)" check_eq $P/us-other-salt-exc "BadSignature" "$(pv us_other_salt_exc)" # ------------------------------------------- URLSafeTimedSerializer ---------- # The two properties together -- URL-safe AND expiring -- which is the # combination Flask's session cookie and every password-reset link actually use. # On the same pinned clock, so this literal is stable too. check_eq $P/uts-token \ "eyJ0aWNrZXQiOiJCUklOVE9TLTI0MDgiLCJuZXN0ZWQiOnsibGlzdCI6WzEsMiwzXSwiZmxhZyI6dHJ1ZSwibm9uZSI6bnVsbH0sInVuaWNvZGUiOiJjYWbDqSIsIm51bWJlciI6MTIzNDU2Nzg5MH0.ZVPxAA.OGY8MRpAzUiOxZJxcfaIChY9YX8" \ "$(pv uts_token)" check_eq $P/uts-urlsafe "yes" "$(pv uts_urlsafe)" check_eq $P/uts-roundtrip "yes" "$(pv uts_roundtrip)" check_eq $P/uts-fresh-ok "yes" "$(pv uts_fresh_ok)" # EXPIRY THROUGH THE SERIALISER, and the payload the exception carries with it -- # the field that lets a caller say WHICH expired link was clicked rather than # only that one was. check_eq $P/uts-expired-exc "SignatureExpired" "$(pv uts_expired_exc)" check_eq $P/uts-expired-payload "yes" "$(pv uts_expired_payload)" # `loads_unsafe' NEVER RAISES: it hands back (ok, payload) so a caller can read # an expired or forged token DELIBERATELY. Asserted beside the refusals above so # the distinction between "rejected" and "unreadable" stays visible -- they are # not the same thing, and finding 2 is why. check_eq $P/uts-unsafe-ok-flag "yes" "$(pv uts_unsafe_ok_flag)" check_eq $P/uts-unsafe-payload "yes" "$(pv uts_unsafe_payload)" # --------------------------------------------------- exception hierarchy ----- # THE TYPES A CALLER CATCHES. `except BadSignature' is the documented way to # reject a bad token, and it only works because the specific exceptions descend # from it. A release that reparented one would break every caller silently, and # no behavioural case above would notice. check_eq $P/exc-sigexpired-is-badtime "yes" "$(pv exc_sigexpired_is_badtime)" check_eq $P/exc-badtime-is-badsig "yes" "$(pv exc_badtime_is_badsig)" check_eq $P/exc-badsig-is-baddata "yes" "$(pv exc_badsig_is_baddata)" check_eq $P/exc-badpayload-is-baddata "yes" "$(pv exc_badpayload_is_baddata)" rm -rf "$W" :