Skip to main content

Prefix-Preserving Encryption for URIs
draft-denis-uricrypt-03

Document Type Active Internet-Draft (individual)
Author Frank Denis
Last updated 2025-10-17
RFC stream (None)
Intended RFC status (None)
Formats
Stream Stream state (No stream defined)
Consensus boilerplate Unknown
RFC Editor Note (None)
IESG IESG state I-D Exists
Telechat date (None)
Responsible AD (None)
Send notices to (None)
draft-denis-uricrypt-03
Network Working Group                                           F. Denis
Internet-Draft                                               Fastly Inc.
Intended status: Informational                           17 October 2025
Expires: 20 April 2026

                 Prefix-Preserving Encryption for URIs
                        draft-denis-uricrypt-03

Abstract

   This document specifies URICrypt, a deterministic, prefix-preserving
   encryption scheme for Uniform Resource Identifiers (URIs).  URICrypt
   encrypts URI paths while preserving their hierarchical structure,
   enabling systems that rely on URI prefix relationships to continue
   functioning with encrypted URIs.  The scheme provides authenticated
   encryption for each URI path component, preventing tampering,
   reordering, or mixing of encrypted segments.

Discussion Venues

   This note is to be removed before publishing as an RFC.

   Source for this draft and an issue tracker can be found at
   https://github.com/jedisct1/draft-denis-uricrypt.

Status of This Memo

   This Internet-Draft is submitted in full conformance with the
   provisions of BCP 78 and BCP 79.

   Internet-Drafts are working documents of the Internet Engineering
   Task Force (IETF).  Note that other groups may also distribute
   working documents as Internet-Drafts.  The list of current Internet-
   Drafts is at https://datatracker.ietf.org/drafts/current/.

   Internet-Drafts are draft documents valid for a maximum of six months
   and may be updated, replaced, or obsoleted by other documents at any
   time.  It is inappropriate to use Internet-Drafts as reference
   material or to cite them other than as "work in progress."

   This Internet-Draft will expire on 20 April 2026.

Copyright Notice

   Copyright (c) 2025 IETF Trust and the persons identified as the
   document authors.  All rights reserved.

Denis                     Expires 20 April 2026                 [Page 1]
Internet-Draft                  URICrypt                    October 2025

   This document is subject to BCP 78 and the IETF Trust's Legal
   Provisions Relating to IETF Documents (https://trustee.ietf.org/
   license-info) in effect on the date of publication of this document.
   Please review these documents carefully, as they describe your rights
   and restrictions with respect to this document.

Table of Contents

   1.  Introduction  . . . . . . . . . . . . . . . . . . . . . . . .   3
     1.1.  Use Cases and Motivations . . . . . . . . . . . . . . . .   3
   2.  Terminology . . . . . . . . . . . . . . . . . . . . . . . . .   4
   3.  URI Processing  . . . . . . . . . . . . . . . . . . . . . . .   5
     3.1.  URI Component Extraction  . . . . . . . . . . . . . . . .   6
       3.1.1.  Full URIs . . . . . . . . . . . . . . . . . . . . . .   7
       3.1.2.  Path-Only URIs  . . . . . . . . . . . . . . . . . . .   7
     3.2.  Component Reconstruction  . . . . . . . . . . . . . . . .   8
   4.  Cryptographic Operations  . . . . . . . . . . . . . . . . . .   9
     4.1.  XOF Initialization  . . . . . . . . . . . . . . . . . . .  10
     4.2.  Component Encryption  . . . . . . . . . . . . . . . . . .  12
     4.3.  Component Decryption  . . . . . . . . . . . . . . . . . .  13
       4.3.1.  Component Boundary Detection  . . . . . . . . . . . .  13
     4.4.  Padding and Encoding  . . . . . . . . . . . . . . . . . .  14
   5.  Algorithm Specification . . . . . . . . . . . . . . . . . . .  14
     5.1.  Encryption Algorithm  . . . . . . . . . . . . . . . . . .  16
     5.2.  Decryption Algorithm  . . . . . . . . . . . . . . . . . .  17
   6.  Implementation Details  . . . . . . . . . . . . . . . . . . .  18
     6.1.  TurboSHAKE128 Usage . . . . . . . . . . . . . . . . . . .  18
     6.2.  Key and Context Handling  . . . . . . . . . . . . . . . .  18
     6.3.  Error Handling  . . . . . . . . . . . . . . . . . . . . .  19
   7.  Security Guarantees . . . . . . . . . . . . . . . . . . . . .  19
     7.1.  Confidentiality . . . . . . . . . . . . . . . . . . . . .  19
     7.2.  Authenticity and Integrity  . . . . . . . . . . . . . . .  20
     7.3.  Prefix-Preserving Property  . . . . . . . . . . . . . . .  20
     7.4.  Domain Separation . . . . . . . . . . . . . . . . . . . .  20
     7.5.  Key Commitment  . . . . . . . . . . . . . . . . . . . . .  21
     7.6.  Resistance to Common Attacks  . . . . . . . . . . . . . .  21
     7.7.  Security Bounds . . . . . . . . . . . . . . . . . . . . .  21
     7.8.  Limitations and Trade-offs  . . . . . . . . . . . . . . .  21
   8.  Security Considerations . . . . . . . . . . . . . . . . . . .  22
   IANA Considerations . . . . . . . . . . . . . . . . . . . . . . .  23
   Acknowledgments . . . . . . . . . . . . . . . . . . . . . . . . .  23
   Normative References  . . . . . . . . . . . . . . . . . . . . . .  23
   Appendix A.  Pseudocode . . . . . . . . . . . . . . . . . . . . .  24
     A.1.  URI Component Extraction  . . . . . . . . . . . . . . . .  24
     A.2.  XOF Initialization  . . . . . . . . . . . . . . . . . . .  25
     A.3.  Encryption Algorithm  . . . . . . . . . . . . . . . . . .  26
     A.4.  Decryption Algorithm  . . . . . . . . . . . . . . . . . .  27
     A.5.  Padding and Encoding  . . . . . . . . . . . . . . . . . .  29

Denis                     Expires 20 April 2026                 [Page 2]
Internet-Draft                  URICrypt                    October 2025

   Appendix B.  Test Vectors . . . . . . . . . . . . . . . . . . . .  29
     B.1.  Test Vector 1: Full URI . . . . . . . . . . . . . . . . .  30
     B.2.  Test Vector 2: Path-Only URI  . . . . . . . . . . . . . .  30
     B.3.  Test Vector 3: Multi-Component Path . . . . . . . . . . .  30
     B.4.  Test Vector 4: Root with Scheme . . . . . . . . . . . . .  30
     B.5.  Test Vector 5: Simple Path  . . . . . . . . . . . . . . .  30
     B.6.  Test Vector 6: URI with Query Parameters  . . . . . . . .  30
     B.7.  Test Vector 7: URI with Fragment  . . . . . . . . . . . .  30
     B.8.  Test Vector 8: URI with Query and Fragment  . . . . . . .  31
   Author's Address  . . . . . . . . . . . . . . . . . . . . . . . .  31

1.  Introduction

   This document specifies URICrypt, a method for encrypting Uniform
   Resource Identifiers (URIs) while preserving their hierarchical
   structure.  The primary motivation is to enable systems that rely on
   URI prefix relationships for routing, filtering, or access control to
   continue functioning with encrypted URIs.

   URICrypt achieves prefix preservation through a chained encryption
   model where the encryption of each URI component depends
   cryptographically on all preceding components.  This ensures that
   URIs sharing common prefixes produce ciphertexts that also share
   common encrypted prefixes.

   The scheme uses an extendable-output function (XOF) as its
   cryptographic primitive and provides authenticated encryption for
   each component, preventing tampering, reordering, or mixing of
   encrypted segments.  URICrypt is a reversible encryption scheme:
   encrypted URIs can be fully decrypted to recover the original URIs,
   but only with possession of the secret key.

1.1.  Use Cases and Motivations

   The main motivations include:

   *  Access Control in CDNs: Content Delivery Networks often use URI
      prefixes for routing and access control.  URICrypt allows
      encryption of resource paths while preserving the prefix structure
      needed for CDN operations.

   *  Privacy-Preserving Logging: Systems can log encrypted URIs without
      exposing sensitive path information, while still enabling analysis
      based on URI structure.

   *  Confidential Data Sharing: When sharing links to sensitive
      resources, URICrypt prevents the path structure itself from
      revealing confidential information.

Denis                     Expires 20 April 2026                 [Page 3]
Internet-Draft                  URICrypt                    October 2025

   *  Token-Based Access Systems: Systems that issue time-limited access
      tokens can use URICrypt to obfuscate the underlying resource
      location while maintaining routability.

   *  Multi-tenant Systems: In systems where multiple tenants share
      infrastructure, URICrypt can isolate tenant data while allowing
      shared components to be processed efficiently.

   *  Privacy-preserving Analytics: URICrypt can complement IPCrypt
      [I-D.draft-denis-ipcrypt].  Together, they enable systems to
      perform analytics on encrypted network flows and resource access
      patterns without exposing sensitive information about either the
      network endpoints or the specific resources being accessed.

2.  Terminology

   The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”,
   “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and
   “OPTIONAL” in this document are to be interpreted as described in BCP
   14 [RFC2119] [RFC8174] when, and only when, they appear in all
   capitals, as shown here.

   Throughout this document, the following terms and conventions apply:

   *  URI: Uniform Resource Identifier as defined in [RFC3986].

   *  URI Component: A segment of a URI path, terminated by ‘/’, ‘?’, or
      ‘#’ characters.  For encryption purposes, components include the
      trailing terminator except for the final component.

   *  Scheme: The URI scheme (e.g., “https://”) which is preserved in
      plaintext.

   *  XOF: Extendable-Output Function, a cryptographic function that can
      produce output of arbitrary length.

   *  SIV: Synthetic Initialization Vector, a value derived from the
      accumulated state of all previous components, used for
      authentication and as input to keystream generation.

   *  SIVLEN: The length of the Synthetic Initialization Vector in
      bytes, defined as 16 bytes (128 bits) for this specification.

   *  PADBS: Padding Block Size, the number of bytes to which ciphertext
      components are aligned.  Defined as 3 bytes for this specification
      to ensure efficient Base64url encoding without padding characters.

Denis                     Expires 20 April 2026                 [Page 4]
Internet-Draft                  URICrypt                    October 2025

   *  Domain Separation: The practice of using distinct inputs to
      cryptographic functions to ensure outputs for different purposes
      are not compatible.

   *  Prefix-preserving Encryption: An encryption scheme where, if two
      plaintexts share a common prefix, their corresponding ciphertexts
      also share a common (encrypted) prefix.

   *  Chained Encryption: A mode where encryption of each component
      depends cryptographically on all preceding components.

3.  URI Processing

   This section describes how URIs are processed for encryption and
   decryption.

   The overall encryption flow transforms a plaintext URI into an
   encrypted URI while preserving its hierarchical structure:

Denis                     Expires 20 April 2026                 [Page 5]
Internet-Draft                  URICrypt                    October 2025

   +-------------------------------------------------------------+
   |                         Input URI                           |
   |          "https://example.com/path/to/resource"             |
   +-------------------------------------------------------------+
                                 |
                                 v
   +-------------------------------------------------------------+
   |                    URI Decomposition                        |
   +-------------------------------------------------------------+
   |  Scheme: "https://"                                         |
   |  Components: ["example.com/", "path/", "to/", "resource"]   |
   +-------------------------------------------------------------+
                                 |
                                 v
   +-------------------------------------------------------------+
   |                 Chained Encryption Process                  |
   +-------------------------------------------------------------+
   |  For each component in sequence:                            |
   |    1. Update state with plaintext                           |
   |    2. Generate SIV from accumulated state                   |
   |    3. Derive keystream using SIV                            |
   |    4. Encrypt component with keystream                      |
   |    5. Output: SIV || encrypted_component                    |
   +-------------------------------------------------------------+
                                 |
                                 v
   +-------------------------------------------------------------+
   |                    Encoding & Assembly                      |
   +-------------------------------------------------------------+
   |  1. Concatenate all (SIV || encrypted_component) pairs      |
   |  2. Apply base64url encoding                                |
   |  3. Prepend original scheme                                 |
   +-------------------------------------------------------------+
                                 |
                                 v
   +-------------------------------------------------------------+
   |                       Encrypted URI                         |
   |          "https://HOGo9vauZ3b3xsPNPQng5apS..."              |
   +-------------------------------------------------------------+

3.1.  URI Component Extraction

   Before encryption, a URI must be split into its scheme and path
   components.  The path is further divided into individual components
   for chained encryption.  Components are terminated by ‘/’, ‘?’, or
   ‘#’ characters, which allows proper handling of query strings and
   fragments.

Denis                     Expires 20 April 2026                 [Page 6]
Internet-Draft                  URICrypt                    October 2025

3.1.1.  Full URIs

   For a full URI including a scheme:

   Input:  "https://example.com/a/b/c"

   Components:

   - Scheme: "https://"
   - Component 1: "example.com/"
   - Component 2: "a/"
   - Component 3: "b/"
   - Component 4: "c"

   For a URI with query parameters:

   Input:  "https://example.com/path?foo=bar&baz=qux"

   Components:

   - Scheme: "https://"
   - Component 1: "example.com/"
   - Component 2: "path?"
   - Component 3: "foo=bar&baz=qux"

   For a URI with a fragment:

   Input:  "https://example.com/path#section"

   Components:

   - Scheme: "https://"
   - Component 1: "example.com/"
   - Component 2: "path#"
   - Component 3: "section"

   Note that all components except the last include their trailing
   terminator character (‘/’, ‘?’, or ‘#’).  This ensures proper
   reconstruction during decryption.

3.1.2.  Path-Only URIs

   For absolute paths (URIs starting with ‘/’ but without a scheme), the
   leading ‘/’ is treated as the first component:

Denis                     Expires 20 April 2026                 [Page 7]
Internet-Draft                  URICrypt                    October 2025

   Input:  "/a/b/c"

   Components:

   - Scheme: "" (empty)
   - Component 1: "/"
   - Component 2: "a/"
   - Component 3: "b/"
   - Component 4: "c"

   For a path with query parameters:

   Input:  "/path/to/file?param=value"

   Components:

   - Scheme: "" (empty)
   - Component 1: "/"
   - Component 2: "path/"
   - Component 3: "to/"
   - Component 4: "file?"
   - Component 5: "param=value"

   The leading ‘/’ is explicitly encrypted as a component to maintain
   consistency and enable proper prefix preservation for absolute paths.

   This character receives its own SIV and is encrypted, ensuring that
   the root path is authenticated like any other path component and that
   different keys and contexts produce different ciphertexts for that
   path, consistently with other paths.

   In applications where all paths are guaranteed to be absolute and the
   '/' path can be considered a special case, ciphertext expansion can
   be reduced by removing the leading '/' character from the URI prior
   to encryption, treating the path as relative with '/' as implicit.

3.2.  Component Reconstruction

   During decryption, components are joined to reconstruct the original
   path:

   Components: ["example.com/", "a/", "b/", "c"]
   Reconstructed Path: "example.com/a/b/c"

   When combined with the scheme: "https://example.com/a/b/c"

   For absolute paths without a scheme:

Denis                     Expires 20 April 2026                 [Page 8]
Internet-Draft                  URICrypt                    October 2025

   Components: ["/", "a/", "b/", "c"]
   Reconstructed Path: "/a/b/c"

4.  Cryptographic Operations

   The chained encryption model creates cryptographic dependencies
   between components, ensuring prefix preservation.

     URI: "https://example.com/path/to/resource"

     +-------------------+
     |   Component 1:    |
     |  "example.com/"   |
     +-------------------+
               |
               | Plaintext absorbed into components_xof
               v
     +-------------------+
     | SIV1 generation   |------> SIV1 (SIVLEN bytes)
     +-------------------+         |
                                   |
                                   v
                         Encrypt("example.com/")
                                   |
                                   v
                         Output1 = SIV1 || Ciphertext1
               |
               | State carries forward
               v
     +-------------------+
     |   Component 2:    |
     |     "path/"       |
     +-------------------+
               |
               | Plaintext absorbed (includes Component 1 state)
               v
     +-------------------+
     | SIV2 generation   |------> SIV2 (depends on 1)
     +-------------------+         |
                                   |
                                   v
                         Encrypt("path/")
                                   |
                                   v
                         Output2 = SIV2 || Ciphertext2
               |
               | State carries forward
               v

Denis                     Expires 20 April 2026                 [Page 9]
Internet-Draft                  URICrypt                    October 2025

     +-------------------+
     |   Component 3:    |
     |      "to/"        |
     +-------------------+
               |
               | Plaintext absorbed (includes 1 + 2 state)
               v
     +-------------------+
     | SIV3 generation   |------> SIV3 (depends on 1, 2)
     +-------------------+         |
                                   |
                                   v
                         Encrypt("to/")
                                   |
                                   v
                         Output3 = SIV3 || Ciphertext3
               |
               | State carries forward
               v
     +-------------------+
     |   Component 4:    |
     |    "resource"     |
     +-------------------+
               |
               | Plaintext absorbed (includes 1 + 2 + 3 state)
               v
     +-------------------+
     | SIV4 generation   |------> SIV4 (depends on 1, 2, 3)
     +-------------------+         |
                                   |
                                   v
                         Encrypt("resource")
                                   |
                                   v
                         Output4 = SIV4 || Ciphertext4

     Final Output: Output1 || Output2 || Output3 || Output4

   If URIs share a common prefix example.com/path/, their Output1 and
   Output2 will be identical.

4.1.  XOF Initialization

   The base XOF is initialized with the secret key and context
   parameters using length-prefixed encoding to prevent ambiguities.

   Two XOF instances are derived from the base XOF:

Denis                     Expires 20 April 2026                [Page 10]
Internet-Draft                  URICrypt                    October 2025

   1.  Components XOF: Updated with each component’s plaintext to
       generate SIVs

   2.  Base Keystream XOF: Used as the starting point for generating
       keystream for each component

     Input: len(key) || key || len(context) || context

     +-----------------------------------------------------+
     | base_xof = TurboSHAKE128(domain_sep=0x1F)           |
     | base_xof.update(len(secret_key))                    |
     | base_xof.update(secret_key)                         |
     | base_xof.update(len(context))                       |
     | base_xof.update(context)                            |
     +-----------------------------------------------------+
                               |
                               v
                  +------------------------+
                  |   Clone Base State     |
                  +------------------------+
                               |
              +----------------+----------------+
              v                                 v
     +--------------------+          +--------------------+
     |  Components XOF    |          | Base Keystream XOF |
     +--------------------+          +--------------------+
     |   update("IV")     |          |   update("KS")     |
     +--------------------+          +--------------------+
              |                                 |
              |                                 |
              v                                 v
      For SIV Generation              For Keystream Base
      (Updated with each              (Cloned for each
       component plaintext)            component's keystream)

   The initialization process is:

   base_xof = TurboSHAKE128()
   base_xof.update(len(secret_key))
   base_xof.update(secret_key)
   base_xof.update(len(context))
   base_xof.update(context)

   components_xof = base_xof.clone()
   components_xof.update("IV")

   base_keystream_xof = base_xof.clone()
   base_keystream_xof.update("KS")

Denis                     Expires 20 April 2026                [Page 11]
Internet-Draft                  URICrypt                    October 2025

   Note on XOF cloning: The .clone() operation creates a new XOF
   instance with an identical internal state, preserving all previously
   absorbed data.  After cloning, the original and cloned XOFs can be
   updated and read from independently.  This allows the components_xof
   to maintain a running state across all components while
   base_keystream_xof remains unchanged for creating per-component
   keystreams.

4.2.  Component Encryption

   For each component, the encryption process follows a precise sequence
   that ensures both confidentiality and authenticity:

   1.  Update components_xof with the component plaintext

   2.  Squeeze the SIV from components_xof (SIVLEN bytes).  This
       requires cloning components_xof before reading, as reading may
       finalize the XOF.

   3.  Create keystream_xof by cloning base_keystream_xof and updating
       it with SIV

   4.  Calculate padding needed for base64 encoding

   5.  Generate a keystream of length (component_length + padding)

   6.  XOR the padded component with the keystream

   7.  Output SIV concatenated with encrypted_component

   The padding ensures clean base64url encoding without padding
   characters.  Since base64 encoding works with groups of 3 bytes
   (producing 4 characters), we pad each (SIV || encrypted_component)
   pair to have a length that’s a multiple of PADBS:

   total_bytes = SIVLEN (SIV) + component_len
   padding_len = (PADBS - total_bytes % PADBS) % PADBS

   This formula calculates:

   *  How many bytes are needed to reach the next multiple of PADBS

   *  The outer modulo handles the case where total_bytes is already a
      multiple of PADBS

Denis                     Expires 20 April 2026                [Page 12]
Internet-Draft                  URICrypt                    October 2025

   The components_xof maintains state across all components.  After
   generating the SIV for component N, the XOF can be updated with
   component N+1’s plaintext.  This chaining ensures that each
   component’s encryption depends on all previous components, thus
   enabling the prefix-preserving property.

4.3.  Component Decryption

   For each encrypted component, the decryption process is:

   1.  Read SIV from input (SIVLEN bytes)

   2.  Create keystream_xof by cloning base_keystream_xof and updating
       it with SIV

   3.  Decrypt bytes incrementally to determine component boundaries:

       *  Generate keystream bytes one at a time from the XOF

       *  XOR each encrypted byte with its corresponding keystream byte

       *  Check each decrypted byte for component terminators ('/', '?',
          '#')

       *  When a terminator is found, the component is complete.

       *  Skip any padding bytes (null bytes) after the component

   4.  Update components_xof with the complete plaintext component
       (including terminator)

   5.  Generate the expected SIV from components_xof

   6.  Compare the expected SIV with the received SIV (constant-time)

   7.  If mismatch, return error

4.3.1.  Component Boundary Detection

   During decryption, component boundaries are discovered dynamically by
   examining the decrypted plaintext:

   *  Each component (except possibly the last) ends with a terminator
      character ('/', '?', or '#')

   *  When a terminator is encountered, we know the component is
      complete

Denis                     Expires 20 April 2026                [Page 13]
Internet-Draft                  URICrypt                    October 2025

   *  After finding the terminator, we skip padding bytes to align to
      the next PADBS-byte boundary.

   *  The padding length can be calculated: padding = (PADBS - ((SIVLEN
      + bytes_read) % PADBS)) % PADBS

   This approach eliminates the need for explicit length encoding, as
   the component structure itself provides the necessary boundary
   information.

   Any tampering with the encrypted data will cause the SIV comparison
   to fail.

4.4.  Padding and Encoding

   To enable clean base64url encoding without padding characters (‘=’),
   each encrypted component pair (SIV || ciphertext) is padded to be a
   multiple of PADBS bytes.  This is necessary because base64 encoding
   processes 3 bytes at a time to produce 4 characters of output.

   The padding calculation (PADBS - (SIVLEN + component_len) % PADBS) %
   PADBS ensures the following:

   *  If (SIVLEN + component_len) % PADBS = 0: no padding needed
      (already aligned)

   *  If (SIVLEN + component_len) % PADBS = 1: add 2 bytes of padding

   *  If (SIVLEN + component_len) % PADBS = 2: add 1 byte of padding

   With the default value of PADBS=3, this padding scheme provides
   partial length-hiding.  For example, with SIVLEN=16, components
   “abc”, “abcd”, and “abcde” all produce 21-byte outputs after padding.
   Without the secret key, a passive adversary cannot determine the
   exact original component size.

   The final output is encoded using URL-safe base64 [RFC4648], with ‘-‘
   replacing ‘+’ and ‘_’ replacing ‘/’ for URI compatibility.

5.  Algorithm Specification

   This section provides the complete algorithms for encryption and
   decryption.  The following functions and operations are used
   throughout the algorithms:

Denis                     Expires 20 April 2026                [Page 14]
Internet-Draft                  URICrypt                    October 2025

   *  TurboSHAKE128(): Creates a new TurboSHAKE128 XOF instance with
      domain separation parameter 0x1F.  This function produces an
      extensible output function (XOF) that can generate arbitrary-
      length outputs.

   *  .update(data): Absorbs the provided data into the XOF state.  Data
      is processed sequentially and updates the internal state of the
      XOF.

   *  .read(length): Squeezes the specified number of bytes from the
      XOF’s output.  Each call continues from where the previous read
      left off, producing a continuous stream of pseudorandom bytes.

   *  .clone(): Creates a new XOF instance with an identical internal
      state to the original.  This enables multiple independent
      computation paths from the same initial state.

   *  XOR operation: The bitwise exclusive OR operation between two byte
      sequences of equal length.  This operation is used to combine
      plaintext with keystream for encryption, and ciphertext with
      keystream for decryption.

   *  base64url_encode(data): Converts binary data to a base64 string
      using URL-safe encoding (replacing ‘+’ with ‘-‘ and ‘/’ with ‘_’)
      and omitting padding characters.

   *  base64url_decode(string): Converts a URL-safe base64 string back
      to binary data, automatically handling the absence of padding
      characters.

   *  Stream(data): Creates a sequential reader for binary data,
      enabling byte-by-byte or block-based access to the contents.

   *  constant_time_compare(a, b): Compares two byte sequences in
      constant time, regardless of their contents.  This prevents timing
      attacks by ensuring the comparison duration does not depend on
      which bytes differ.

   *  len(data): Returns the length of the provided data in bytes.

   *  Concatenation: The operation of joining two byte sequences end-to-
      end to form a single sequence.

   *  zeros(count): Generates a sequence of zero-valued bytes of the
      specified length, used for padding.

   *  remove_padding(data): Removes trailing zero bytes from a byte
      sequence to recover the original data length.

Denis                     Expires 20 April 2026                [Page 15]
Internet-Draft                  URICrypt                    October 2025

   *  join(components): Combines multiple path components into a single
      path string, preserving the terminator characters ('/', '?', '#')
      that are included in each component.

5.1.  Encryption Algorithm

   Input: secret_key, context, uri_string

   Output: encrypted_uri

   Steps:

   1.  Split URI into scheme and components

   2.  Initialize XOF instances as described in Section 4.1

   3.  encrypted_output = empty byte array

   4.  For each component:

       *  Update components_xof with component

       *  SIV = components_xof.clone().read(SIVLEN)

       *  keystream_xof = base_keystream_xof.clone()

       *  keystream_xof.update(SIV)

       *  padding_len = (PADBS - (SIVLEN + len(component)) % PADBS) %
          PADBS

       *  keystream = keystream_xof.read(len(component) + padding_len)

       *  padded_component = component concatenated with
          zeros(padding_len)

       *  encrypted_part = padded_component XOR keystream

       *  encrypted_output = encrypted_output concatenated with SIV
          concatenated with encrypted_part

   5.  base64_output = base64url_encode(encrypted_output)

   6.  If scheme is not empty: Return scheme + base64_output

   7.  Else if original URI started with ‘/’: Return '/' + base64_output

   8.  Else: Return base64_output

Denis                     Expires 20 April 2026                [Page 16]
Internet-Draft                  URICrypt                    October 2025

5.2.  Decryption Algorithm

   Input: secret_key, context, encrypted_uri

   Output: decrypted_uri or error

   Note: For path-only URIs (those starting with ‘/’), the output format
   is: - ‘/’ followed by the base64url-encoded encrypted components -
   This preserves the absolute path indicator in the encrypted form

   Steps:

   1.  Split encrypted URI into scheme and base64 part

   2.  decoded = base64url_decode(base64_part) If decoding fails, return
       error

   3.  Initialize XOF instances as described in Section 4.1

   4.  decrypted_components = empty list

   5.  position = 0

   6.  While position < len(decoded):

       *  SIV = decoded[position:position+SIVLEN] If not enough bytes,
          return error

       *  keystream_xof = base_keystream_xof.clone()

       *  keystream_xof.update(SIV)

       *  component_start = position + SIVLEN

       *  component = empty byte array

       *  position = position + SIVLEN

       *  While position < len(decoded):

          -  decrypted_byte = decoded[position] XOR
             keystream_xof.read(1)

          -  position = position + 1

          -  If decrypted_byte == 0x00: continue (skip padding)

          -  component.append(decrypted_byte)

Denis                     Expires 20 April 2026                [Page 17]
Internet-Draft                  URICrypt                    October 2025

          -  If decrypted_byte is '/', '?', or '#':

             o  total_len = position - component_start

             o  position = position + ((PADBS - ((SIVLEN + total_len) %
                PADBS)) % PADBS)

             o  Break inner loop

       *  Update components_xof with component

       *  expected_SIV = components_xof.clone().read(SIVLEN)

       *  If constant_time_compare(SIV, expected_SIV) == false, return
          error

       *  decrypted_components.append(component)

   7.  decrypted_path = join(decrypted_components)

   8.  Return scheme + decrypted_path

6.  Implementation Details

6.1.  TurboSHAKE128 Usage

   Implementations MUST use TurboSHAKE128 with a domain separation
   parameter of 0x1F for all operations.  The TurboSHAKE128 XOF is used
   for:

   *  Generating SIVs from the components XOF

   *  Generating keystream for encryption/decryption

   *  All XOF operations in the initialization

   TurboSHAKE128 is specified in [RFC9861] and provides the security
   properties needed for this construction.

6.2.  Key and Context Handling

   The secret key MUST be at least SIVLEN bytes long.  Keys shorter than
   SIVLEN bytes MUST be rejected.  Implementations SHOULD validate that
   the key does not consist of repeated patterns (e.g., identical first
   and second halves) as a best practice.

Denis                     Expires 20 April 2026                [Page 18]
Internet-Draft                  URICrypt                    October 2025

   The context parameter is a string that provides domain separation.
   Different applications SHOULD use different context strings to
   prevent cross-application attacks.  The context string MAY be empty.

   Both key and context are length-prefixed when absorbed into the base
   XOF:

   base_xof.update(len(secret_key) as uint8)
   base_xof.update(secret_key)
   base_xof.update(len(context) as uint8)
   base_xof.update(context)

   The length is encoded as a single byte, limiting keys and contexts to
   255 bytes.  This is sufficient for all practical use cases.

6.3.  Error Handling

   Implementations MUST NOT reveal the cause of decryption failures.
   All error conditions (invalid base64, incorrect padding, SIV
   mismatch, insufficient data) MUST result in identical, generic error
   messages.

   SIV comparison MUST be performed in constant-time to prevent timing
   attacks.

7.  Security Guarantees

   URICrypt provides the following cryptographic security guarantees:

7.1.  Confidentiality

   URICrypt achieves semantic security for URI path components through
   its use of TurboSHAKE128 as a pseudorandom function.  Each component
   is encrypted using a unique keystream derived from the following:

   *  The secret key

   *  The application context

   *  A synthetic initialization vector (SIV) that depends on all
      preceding components

   This construction ensures that:

   *  An attacker without the secret key cannot recover plaintext
      components from ciphertexts.

Denis                     Expires 20 April 2026                [Page 19]
Internet-Draft                  URICrypt                    October 2025

   *  The keystream generation is computationally indistinguishable from
      random for each unique (key, context, path-prefix) tuple.

   *  Components are protected by at least 128 bits of security against
      brute-force attacks.

7.2.  Authenticity and Integrity

   Each URI component is authenticated through the SIV mechanism:

   *  The SIV acts as a Message Authentication Code (MAC) computed over
      the component and all preceding components.

   *  Any modification to a component will cause the SIV verification to
      fail during decryption.

   *  The chained construction ensures that reordering, insertion, or
      deletion of components is detected.

   *  Authentication provides 128-bit security against forgery attempts.

7.3.  Prefix-Preserving Property

   URICrypt maintains a controlled information leakage pattern:

   *  URIs sharing a common prefix will produce ciphertexts with the
      same encrypted prefix.

   *  This property is deterministic and intentional, enabling systems
      to perform prefix-based operations.

   *  The leakage is limited to prefix structure only—no information
      about non-matching suffixes is revealed.

7.4.  Domain Separation

   The context parameter provides cryptographic domain separation:

   *  Different contexts with the same key produce completely
      independent ciphertexts.

   *  This prevents cross-context attacks where ciphertexts from one
      application could be used in another.

   *  Context binding is cryptographically enforced through the XOF
      initialization.

Denis                     Expires 20 April 2026                [Page 20]
Internet-Draft                  URICrypt                    October 2025

7.5.  Key Commitment

   URICrypt provides full key-commitment security.

   The scheme is fully key-committing, meaning that a ciphertext can
   only be decrypted with the exact key that was used to encrypt it.  It
   is computationally infeasible to find two different keys that
   successfully decrypt the same ciphertext to valid plaintexts.

7.6.  Resistance to Common Attacks

   URICrypt resists several categories of attacks:

   Chosen-plaintext Attacks (CPA): While deterministic, URICrypt is CPA-
   secure for unique inputs.  The determinism is a design requirement
   for prefix preservation.

   Tampering Detection: Any bit flip, truncation, or modification in the
   ciphertext will be detected with overwhelming probability (1 - 2^-
   128).

   Length-extension Attacks: The use of length-prefixed encoding and
   domain separation prevents length-extension attacks.

   Replay Attacks: Within a single (key, context) pair, replay is
   possible due to determinism.  Applications requiring replay
   protection should incorporate timestamps or nonces into the context.

   Key Recovery: TurboSHAKE128’s security properties ensure that
   observing ciphertexts does not leak information about the secret key.

7.7.  Security Bounds

   The security of URICrypt is bounded by the following:

   *  Key strength: Minimum 128-bit security with SIVLEN-byte keys

   *  Collision resistance: 2^64 birthday bound for SIV collisions

   *  Authentication security: 2^-128 probability of successful forgery

   *  Computational security: Based on TurboSHAKE128’s proven security
      as an XOF

7.8.  Limitations and Trade-offs

   URICrypt makes specific security trade-offs for functionality,
   including the following:

Denis                     Expires 20 April 2026                [Page 21]
Internet-Draft                  URICrypt                    October 2025

   *  Deterministic encryption: Same inputs produce same outputs,
      enabling certain traffic analysis

   *  Partial length obfuscation: With PADBS=3, exact component lengths
      are partially hidden

   *  Prefix structure leakage: The hierarchical structure of URIs is
      preserved by design

   *  SIV length configuration: Implementations MAY adjust SIVLEN for
      different usage bounds.  Larger values (24 or 32 bytes) increase
      birthday bound resistance at the cost of ciphertext expansion.
      However, 16 bytes is generally recommended as it provides
      practical collision resistance with acceptable overhead

   *  Padding block size configuration: The default PADBS=3 already
      provides partial length-hiding.  Implementations MAY adjust PADBS
      to increase size obfuscation.  Larger values create larger size
      buckets but increase ciphertext expansion.  The value MUST remain
      a multiple of 3 to ensure efficient Base64url encoding without
      padding characters

   These trade-offs are intentional and necessary for the prefix-
   preserving functionality.  Applications requiring stronger privacy
   guarantees should evaluate whether URICrypt’s properties align with
   their threat model.

8.  Security Considerations

   URICrypt provides confidentiality and integrity for URI paths while
   preserving prefix relationships.  The encryption is fully reversible:
   encrypted URIs can be decrypted to recover the original plaintext
   URIs, but only with knowledge of the secret key.  The security
   properties depend on:

   *  Key Secrecy: The security of URICrypt depends entirely on the
      secrecy of the secret key.  Keys MUST be generated using a
      cryptographically secure random number generator [RFC4086] and
      stored securely.

   *  Deterministic Encryption: URICrypt is deterministic - identical
      inputs produce identical outputs.  This allows observers to detect
      when the same URI is encrypted multiple times.  Applications
      requiring unlinkability SHOULD incorporate additional entropy
      (e.g., via the context parameter).

Denis                     Expires 20 April 2026                [Page 22]
Internet-Draft                  URICrypt                    October 2025

   *  Prefix Preservation: While essential for functionality, prefix
      preservation leaks information about URI structure.  Systems where
      this information is sensitive SHOULD consider alternative
      approaches.

   *  Context Separation: The context parameter prevents cross-context
      attacks.  Applications MUST use distinct contexts for different
      purposes, even when sharing keys.

   *  Component Authentication: Each component is authenticated via the
      SIV mechanism.  Any modification, reordering, or truncation of
      components will be detected during decryption.

   *  Length Obfuscation: The default PADBS=3 configuration provides
      partial length-hiding.  Applications requiring stronger length-
      hiding SHOULD consider using larger PADBS values or padding
      components to fixed lengths.

   *  Key Reuse: Using the same key with different contexts is safe, but
      using the same (key, context) pair for different applications is
      NOT RECOMMENDED.

IANA Considerations

   This document has no actions for IANA.

Acknowledgments

   The author would like to thank Maciej Soltysiak for highlighting the
   importance of properly supporting query parameters and fragments in
   URI encryption.

Normative References

   [I-D.draft-denis-ipcrypt]
              Denis, F., "Methods for IP Address Encryption and
              Obfuscation", Work in Progress, Internet-Draft, draft-
              denis-ipcrypt-12, 19 September 2025,
              <https://datatracker.ietf.org/doc/html/draft-denis-
              ipcrypt-12>.

   [RFC2119]  Bradner, S., "Key words for use in RFCs to Indicate
              Requirement Levels", BCP 14, RFC 2119,
              DOI 10.17487/RFC2119, March 1997,
              <https://www.rfc-editor.org/rfc/rfc2119>.

Denis                     Expires 20 April 2026                [Page 23]
Internet-Draft                  URICrypt                    October 2025

   [RFC3986]  Berners-Lee, T., Fielding, R., and L. Masinter, "Uniform
              Resource Identifier (URI): Generic Syntax", STD 66,
              RFC 3986, DOI 10.17487/RFC3986, January 2005,
              <https://www.rfc-editor.org/rfc/rfc3986>.

   [RFC4086]  Eastlake 3rd, D., Schiller, J., and S. Crocker,
              "Randomness Requirements for Security", BCP 106, RFC 4086,
              DOI 10.17487/RFC4086, June 2005,
              <https://www.rfc-editor.org/rfc/rfc4086>.

   [RFC4648]  Josefsson, S., "The Base16, Base32, and Base64 Data
              Encodings", RFC 4648, DOI 10.17487/RFC4648, October 2006,
              <https://www.rfc-editor.org/rfc/rfc4648>.

   [RFC8174]  Leiba, B., "Ambiguity of Uppercase vs Lowercase in RFC
              2119 Key Words", BCP 14, RFC 8174, DOI 10.17487/RFC8174,
              May 2017, <https://www.rfc-editor.org/rfc/rfc8174>.

   [RFC9861]  Viguier, B., Wong, D., Ed., Van Assche, G., Ed., Dang, Q.,
              Ed., and J. Daemen, Ed., "KangarooTwelve and TurboSHAKE",
              RFC 9861, DOI 10.17487/RFC9861, October 2025,
              <https://www.rfc-editor.org/rfc/rfc9861>.

Appendix A.  Pseudocode

A.1.  URI Component Extraction

Denis                     Expires 20 April 2026                [Page 24]
Internet-Draft                  URICrypt                    October 2025

   function extract_components(uri_string):
     if uri_string contains "://":
        scheme = substring up to and including "://"
        path = substring after "://"
     else:
        scheme = ""
        path = uri_string

     components = []

     // For absolute paths, treat leading "/" as first component
     if path starts with "/":
        components.append("/")
        path = substring after first "/"

     while path is not empty:
        terminator_pos = find_next_terminator(path)
        if terminator_pos found:
           component = substring(path, 0, terminator_pos + 1)
           path = substring(path, terminator_pos + 1)
           components.append(component)
        else:
           components.append(path)
           path = ""

     return (scheme, components)

   function find_next_terminator(path):
     for i from 0 to length(path) - 1:
        if path[i] == '/' or path[i] == '?' or path[i] == '#':
           return i
     return not_found

A.2.  XOF Initialization

Denis                     Expires 20 April 2026                [Page 25]
Internet-Draft                  URICrypt                    October 2025

   function initialize_xofs(secret_key, context):
     // Initialize base XOF
     base_xof = TurboSHAKE128(0x1F)

     // Absorb key and context with length prefixes
     base_xof.update(uint8(len(secret_key)))
     base_xof.update(secret_key)
     base_xof.update(uint8(len(context)))
     base_xof.update(context)

     // Create components XOF
     components_xof = base_xof.clone()
     components_xof.update("IV")

     // Create base keystream XOF
     base_keystream_xof = base_xof.clone()
     base_keystream_xof.update("KS")

     return (components_xof, base_keystream_xof)

A.3.  Encryption Algorithm

   function uricrypt_encrypt(secret_key, context, uri_string):
     // Extract components
     (scheme, components) = extract_components(uri_string)

     // Initialize XOF instances with secret key and context
     (components_xof, base_keystream_xof) =
         initialize_xofs(secret_key, context)
     if error: return error

     encrypted_output = byte_array()

     // Process each component
     for component in components:
        // Update components XOF for SIV computation
        components_xof.update(component)

        // Generate SIVLEN-byte Synthetic Initialization Vector (SIV)
        siv = components_xof.clone().squeeze(SIVLEN)

        // Create keystream XOF for this component
        keystream_xof = base_keystream_xof.clone()
        keystream_xof.update(siv)

        // Calculate padding for base64 encoding alignment
        // The total bytes (SIV + component) must be a multiple of PADBS
        // to produce clean base64 output without padding characters

Denis                     Expires 20 April 2026                [Page 26]
Internet-Draft                  URICrypt                    October 2025

        component_len = len(component)
        padding_len = (PADBS - (SIVLEN + component_len) % PADBS) % PADBS

        // Generate keystream
        keystream = keystream_xof.squeeze(component_len + padding_len)

        // Pad component to align with base64 encoding requirements
        padded_component = component + byte_array(padding_len)

        // Encrypt using XOR with keystream
        encrypted_part = xor_bytes(padded_component, keystream)

        // Append to output
        encrypted_output.extend(siv)
        encrypted_output.extend(encrypted_part)

     // Base64 encode with URL-safe characters and no padding
     base64_output = base64_urlsafe_no_pad_encode(encrypted_output)

     // Return with appropriate prefix
     if scheme != "":
        return scheme + base64_output
     else if uri_string starts with "/":
        return "/" + base64_output
     else:
        return base64_output

A.4.  Decryption Algorithm

function uricrypt_decrypt(secret_key, context, encrypted_uri):
  // Split scheme and base64
  if encrypted_uri contains "://":
     scheme = substring up to and including "://"
     base64_part = substring after "://"
  else if encrypted_uri starts with "/":
     // Path-only URI: strip leading "/" before decoding
     scheme = ""
     base64_part = substring after first "/"
  else:
     scheme = ""
     base64_part = encrypted_uri

  // Decode base64
  try:
     decoded = base64_urlsafe_no_pad_decode(base64_part)
  catch:
     return error("Decryption failed")

Denis                     Expires 20 April 2026                [Page 27]
Internet-Draft                  URICrypt                    October 2025

  // Initialize XOF instances with secret key and context
  (components_xof, base_keystream_xof) =
      initialize_xofs(secret_key, context)
  if error: return error

  decrypted_components = []
  input_stream = ByteStream(decoded)

  // Process each component
  while not input_stream.empty():
     // Read SIV
     siv = input_stream.read(SIVLEN)
     if len(siv) != SIVLEN:
        return error("Decryption failed")

     // Create keystream XOF
     keystream_xof = base_keystream_xof.clone()
     keystream_xof.update(siv)

     // Decrypt byte-by-byte to find component boundary
     component = byte_array()
     component_start = input_stream.position()

     while not input_stream.empty():
        // Decrypt one byte
        encrypted_byte = input_stream.read(1)
        if len(encrypted_byte) != 1:
           return error("Decryption failed")

        keystream_byte = keystream_xof.squeeze(1)
        decrypted_byte = xor_bytes(encrypted_byte, keystream_byte)[0]

        // Skip padding (null bytes)
        if decrypted_byte == 0x00:
           continue

        // Add to component
        component.append(decrypted_byte)

        // Check for terminator
        if decrypted_byte == '/' or decrypted_byte == '?' or decrypted_byte == '#':
           // Component complete - skip remaining padding
           total_len = input_stream.position() - component_start
           padding_len = (PADBS - ((SIVLEN + total_len) % PADBS)) % PADBS
           input_stream.skip(padding_len)
           break

     // Update XOF with plaintext

Denis                     Expires 20 April 2026                [Page 28]
Internet-Draft                  URICrypt                    October 2025

     components_xof.update(component)

     // Generate expected SIV
     expected_siv = components_xof.clone().squeeze(SIVLEN)

     // Authenticate using constant-time comparison to prevent timing attacks
     if not constant_time_equal(siv, expected_siv):
        return error("Decryption failed")

     decrypted_components.append(component)

  // Reconstruct URI
  path = "".join(decrypted_components)
  return scheme + path

A.5.  Padding and Encoding

function calculate_padding(component_len):
  // Calculate padding needed for base64 encoding alignment
  // The combined SIV (SIVLEN bytes) + component must be divisible by PADBS
  // for clean base64 encoding without '=' padding characters
  total_len = SIVLEN + component_len
  return (PADBS - total_len % PADBS) % PADBS

function base64_urlsafe_no_pad_encode(data):
  // Use standard base64 encoding
  encoded = standard_base64_encode(data)
  // Make URL-safe and remove padding for URI compatibility
  encoded = encoded.replace('+', '-')
                   .replace('/', '_')
                   .rstrip('=')
  return encoded

function base64_urlsafe_no_pad_decode(encoded):
  // Add padding if needed for standard decoder
  padding = (4 - len(encoded) % 4) % 4
  if padding > 0:
     encoded = encoded + ('=' * padding)
  // Make standard base64
  encoded = encoded.replace('-', '+')
                   .replace('_', '/')
  // Decode
  return standard_base64_decode(encoded)

Appendix B.  Test Vectors

   These test vectors were generated using the reference Rust
   implementation of URICrypt with TurboSHAKE128.

Denis                     Expires 20 April 2026                [Page 29]
Internet-Draft                  URICrypt                    October 2025

   Test Configuration:
   secret_key (hex): 0102030405060708090a0b0c0d0e0f10
   context: "test-context"

B.1.  Test Vector 1: Full URI

  Input: "https://example.com/a/b/c"
  Output: "https://HOGo9vauZ3b3xsPNPQng5apSzL5V7QW94C7USgN8mHZJ337AKSWOu
           cUwMuD-uUfF95SsSHCNgBkXUnH1uGll_YtBltXSqKEHNcYJJwbdFdhfWz19"

B.2.  Test Vector 2: Path-Only URI

  Input: "/a/b/c"
  Output: "/b9bCOhqZsvU9XxGOMk6d8QFQhTIdI_xYKpds2lWXpZCms5-az9wtfUft3rec
           3d9YkUo0N7VcxO5MXfxE5UobvgTJX8UpRdNN"

B.3.  Test Vector 3: Multi-Component Path

 Input: "https://cdn.example.com/videos/2025/03/file.mp4"
 Output: "https://hxUM2N3txwYjGxjvCpWn30SznxR0v0fDbkSQgCTXCUu7Rq8iSbWP4
          0OvYxKs9zC3kw1JNzAc4Wuj7RZvRd0VUprJWLs5KJPnWsA9Kguxa_J7XviTS3G
          Tqf-XZdPxYyq1Y1MXVE9_4ojHwm6jBDUkVthAkuNe5Cqk_h6d"

B.4.  Test Vector 4: Root with Scheme

   Input: "https://example.com/"
   Output: "https://HOGo9vauZ3b3xsPNPQng5apSzL5V7QW94C7USgN8"

B.5.  Test Vector 5: Simple Path

  Input: "/path/to/resource"
  Output: "/b9bCOhqZsvU9XxGOMk6d8QFQPTuMlsQKDBhAbc77JvsdRj0kxiFipunATQmm
           CkNhAe0BPP2EqQoxORElY_ukfUYSrr9mIMfiO9joa3Kn5RS7eSKr"

B.6.  Test Vector 6: URI with Query Parameters

  Input: "https://example.com/search?q=test&limit=10"
  Output: "https://HOGo9vauZ3b3xsPNPQng5apSzL5V7QW94C7USgN8cl2BBtuWmxTsI
           Ij59ka3KeDsaqXFGnKgW9aLLR36YvUf9ORkMnVE5PTR_3DiO43hL9WjdSu7L9
           FN"

B.7.  Test Vector 7: URI with Fragment

  Input: "https://docs.example.com/guide#installation"
  Output: "https://ypHTiw0JUMcr4bUjQH9Dxo8wGWHyfFlLq8VrOE-zX6IbgLFxYX_Jm
           2hzivywvrpIBWa-9Jl6nSZLq2pd35QwkDsc1-_Kao2BvyBB19ndu1PpwQv1wy
           uA"

Denis                     Expires 20 April 2026                [Page 30]
Internet-Draft                  URICrypt                    October 2025

B.8.  Test Vector 8: URI with Query and Fragment

  Input: "/api/v2/users?id=123#profile"
  Output: "/b9bCOhqZsvU9XxGOMk6d8QFQwcP2C3bJVNVZDge7zfub_ai4x6LaUlXp-XjZ
           XOgZlLloIbasK-JKlbeKeKV2rctq5bX9zQh1KogN2zaggTMZioUb4kwGIKp8Z
           y744xQwGDG64n6GhN56XEM8LvBfJuEj6ZgsjeLbTPIMbCmO0pJhzVSh"

Author's Address

   Frank Denis
   Fastly Inc.
   Email: fde@00f.net

Denis                     Expires 20 April 2026                [Page 31]