Internet-Draft SAFE July 2026
Sullivan Expires 17 January 2027 [Page]
Workgroup:
Network Working Group
Internet-Draft:
draft-sullivan-safe-latest
Published:
Intended Status:
Standards Track
Expires:
Author:
N. Sullivan
Cryptography Consulting LLC

SAFE: Sealed, Algorithm-Flexible Envelope

Abstract

SAFE defines an encryption envelope that encrypts a payload once for multiple recipients. Decryption can require multiple credentials in sequence (public keys, passphrases, or other registered methods), so that no single compromise reveals content. The format targets large, writable files: it supports streaming decryption, random-access reads at segment granularity, and selective re-encryption of modified segments without re-keying. Per-segment Authenticated Encryption with Associated Data (AEAD) provides confidentiality and integrity and detects reordering, truncation, and extension. SAFE accommodates post-quantum key encapsulation without format changes, provides algorithm agility through IANA registries, supports recipient privacy, and defines application profiles for common deployment scenarios.

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 17 January 2027.

Table of Contents

1. Introduction

SAFE is an encryption format for files and objects. A SAFE-encoded file contains an encrypted payload and one or more LOCK blocks. Each LOCK defines one independently satisfiable access path to the Content-Encryption Key (CEK). Multiple LOCKs let different recipients, roles, groups, or recovery mechanisms decrypt the same payload without duplicating ciphertext. Within a LOCK, all listed Steps are required, so one LOCK can demand several credentials together (a passphrase AND a private key, for example).

The payload is split into fixed-size segments, each encrypted with Authenticated Encryption with Associated Data (AEAD) [RFC5116] and a per-segment nonce, random or derived per the selected profile. Segments can be decrypted individually or streamed sequentially. An aligned binary layout (Section 6.4.2) places each ciphertext segment at a predictable offset, enabling O(1) random reads. Per-segment nonces (Section 5.8.4) allow individual segments to be re-encrypted without re-wrapping the CEK or touching other segments.

Existing formats address subsets of these capabilities; Section 2 surveys the differences. SAFE provides algorithm agility through IANA registries (Section 10) and accommodates post-quantum key encapsulation mechanisms without format changes. Recipient privacy modes (Section 8.7) allow Hybrid Public Key Encryption (HPKE) steps to omit key identifiers, preventing passive observers from linking files to recipients. Application profiles (Section 7.4) provide baseline algorithm guidance for common deployment scenarios.

For example, a deployment might require that documents be decryptable only with both a passphrase AND a recipient private key. The LOCK block for such a recipient would contain two Step lines:

Step: pass(kdf=argon2id, salt=...)
Step: hpke(kem=p-256, id=..., kemct=...)

Both steps are evaluated with the known passphrase and private key to derive the key that wraps the CEK. Neither factor alone suffices. See Section 5.7.1.1 for the cryptographic details.

The payload layer and LOCK layer serve distinct roles. The LOCK layer manages key encapsulation and multi-factor access control, wrapping the CEK independently for each recipient. The payload layer provides per-segment authenticated encryption with support for random-access reads and selective re-encryption of modified segments.

Within the payload layer, a file-level commitment value precedes independently decryptable fixed-size segments. Confidentiality and integrity are enforced per segment. Segment AAD authenticates both the segment index and a final-segment indicator, so a Decryptor can detect segment substitution, reordering, truncation, and extension (Section 5.8.8). A commitment prefix derived from the CEK, the negotiated algorithm parameters, and a per-file salt provides uniform key commitment across all AEAD choices (Section 8.13).

The Profile CONFIG field (Section 6.1) selects one of the named SEAL instantiations (Section 5.8.1). The mutable instantiations permit in-place rewrite of individual segments; the immutable ones follow a write-once discipline, encrypting each segment exactly once.

The payload layer of SAFE is a profile of SEAL, the Segmented Encrypted Authentication Layer construction, specified in [I-D.sullivan-cfrg-raae]; see Section 5.8.1. [FLOE] formalizes the security model as ra-ROR (random-access real-or-random) and ra-CMT (random-access context commitment). It proves that any such construction achieves both properties independently of the underlying AEAD's own commitment behavior.

SAFE's registered AEADs fall into two usage classes: Misuse-Resistant Authenticated Encryption (MRAE, [RFC9771]) and non-MRAE suites. Non-MRAE suites (AES-256-GCM, ChaCha20-Poly1305, AEGIS-256, AEGIS-256X2) depend on per-segment nonce uniqueness and the total number of segment encryptions under a given payload key. They store a random per-segment nonce under a Profile whose nonce mode is random, and use derived nonces under a write-once Profile, where each derived nonce is used exactly once (Section 5.8.1, Section 5.8.4). The MRAE suite (AES-256-GCM-SIV, where SIV denotes Synthetic Initialization Vector) tolerates nonce reuse, degrading to deterministic encryption rather than permitting plaintext recovery. Because segment rewrites consume additional encryptions under the same payload key, the relevant usage budget is not just the current file size but the total lifetime segment encryptions, including all rewrites. See Section 8.14 for per-AEAD analysis.

3. Protocol Overview

This section summarizes the encryption and decryption procedures. Normative details appear in the referenced sections.

Readers can follow a profile-keyed path through the rest of this document. The Profile field, the named instantiations it selects, and the guarantees each delivers are given in Section 5.8.1.

Implementers of the default mutable object (the SEAL-editable Profile, and likewise SEAL-memory and SEAL-compact) read:

Implementers of immutable write-once objects (the SEAL-simple, SEAL-attachment, and SEAL-attachment-small Profiles) read Section 6.1, Section 5.1, and Section 5.8.2. They MAY skip Section 5.8.6, which does not apply. Under SEAL-simple they MAY also skip Section 5.8.7; SEAL-attachment and SEAL-attachment-small carry a snapshot and need it.

A SAFE object is the abstract value that the format encodes. It has three parts: a Suite, a set of LOCKs, and a Payload. The Suite names the cryptographic parameters that govern the object (Profile, AEAD, and KDF). Each LOCK carries an ordered list of steps and one Encrypted-CEK, and represents a single way to recover the CEK. The Payload is the segmented, authenticated ciphertext together with the per-file salt, commitment, and snapshot authenticator. Multiple LOCKs wrap the same CEK, so adding or removing a LOCK changes who can open the object without re-encrypting the Payload.

A step has three forms, distinguished by where it lives. The step_descriptor is the on-wire text that appears verbatim in a LOCK block (for example, hpke(kem=x25519, kemct=..., id=...)). The step_binding is the canonical Encode() form bound into the LOCK key derivation: display-only fields are stripped and the key identifier is reconstructed even when omitted on the wire, so a step is bound to the material it actually used. The step_secret is the 32-octet secret a credential yields for that step. The LOCK key derivation (Section 5.7.1) folds the ordered step secrets and step bindings of a LOCK into that LOCK's KEK, and the payload layer is a profile of SEAL (Section 5.8.1).

Serialization is separate from the object. A LOCK is written in a readable text encoding or an armored encoding (Section 6.2), and the Payload is written in an armored or binary encoding (Section 6.3); these encodings are lossless transcodings of one another, and the choice of encoding does not change the object's cryptographic value or any bound parameter.

Given a plaintext and a set of recipients (each defined by one or more credentials), an Encryptor produces a SAFE object:

  1. Select a Profile, AEAD, and KDF (or use defaults).

  2. Generate a random 32-octet CEK: SafeRandom(32, "SAFE-CEK") (Section 5.4.2).

  3. For each recipient, build a LOCK: generate step artifacts (salts, KEM ciphertexts), derive a KEK, and wrap the CEK.

  4. Generate a 32-octet per-file salt: SafeRandom(32, "SAFE-SALT").

  5. Derive payload_key and snap_key from CEK and salt (Section 5.8.2).

  6. Split plaintext into segments; encrypt each with a per-segment nonce.

  7. Compute the snapshot authenticator over the per-segment inputs it defines (Section 5.8.7).

  8. Write the file: optional CONFIG, LOCK blocks, and payload (the salt leads the payload's raAE header, Section 6.4).

Given a SAFE object and the appropriate credentials (private keys, passphrases, or other step inputs), a Decryptor recovers the plaintext:

  1. Parse CONFIG, LOCK, and DATA blocks.

  2. Try each LOCK until one succeeds: evaluate its steps with the recipient's credentials to derive a KEK, then unwrap the CEK.

  3. Read the 32-octet salt, which leads the raAE header (Section 6.4).

  4. Verify the commitment prefix.

  5. Derive payload_key and snap_key from CEK and salt (Section 5.8.2).

  6. Verify the snapshot as the resolved snap_id allows (Section 5.8.7): SnapVerify over the whole segment set at 0x0001, the authenticator's per-segment procedure at 0x0002 and 0x0003, and nothing at 0x0000.

  7. Decrypt requested segments.

The CEK enables multi-recipient encryption (wrap once per recipient). The KEK binds each recipient's credentials to the CEK. The payload_key is derived from the CEK and the payload_info (the SEAL payload binding; Section 5.8.2), which carries the AEAD, segment size, KDF, snap_id, nonce mode, epoch length, and the per-file salt. This provides domain separation: distinct salts produce distinct payload keys even under the same CEK.

4. Conventions and Notation

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.

Header text is UTF-8. "Base64" in this document refers to the standard Base64 encoding defined in Section 4 of [RFC4648]. The alphabet is the 64-character set A-Z, a-z, 0-9, +, /, with = as the padding character. The URL-safe alphabet defined in Section 5 of [RFC4648] (which substitutes - for + and _ for /) MUST NOT be used. Padding MUST be present; the unpadded form is not permitted. Decryptors MUST reject any Base64 input containing characters outside the standard alphabet, padding characters in invalid positions, or control characters including CR and LF within a single Base64 field value.

When Base64 values appear in SAFE blocks, Encryptors SHOULD wrap lines at 64 characters; Decryptors MUST accept any line length and MUST strip line breaks (LF and CRLF) before decoding. LF denotes the newline U+000A; Encryptors MUST use LF, and Decryptors MUST accept LF and MAY accept CRLF.

String constants used in Encode AAD labels are ASCII; key-layer labels begin with SAFE- (e.g., SAFE-STEP) and the payload AAD label is SEAL-DATA (Section 5.8.1). SafeDerive and SealDerive labels are ASCII (e.g., commit, kek), and the protocol prefix is added automatically: SAFE-v1 for the key layer, and SEAL-RW-v1 or SEAL-RO-v1 for the payload.

ABNF follows [RFC5234]. The following common ABNF rules are used throughout this document:

BASE64CHAR = ALPHA / DIGIT / "+" / "/" / "="
NZDIGIT    = %x31-39       ; "1" through "9"
UINT       = "0" / (NZDIGIT *DIGIT)

BASE64CHAR is a loose character-class approximation; implementations MUST validate Base64 encoding per [RFC4648] Section 4, including correct padding placement. UINT is the canonical decimal ASCII encoding of a non-negative integer: the single character "0" for zero, or one or more decimal digits with no leading zero for positive values. No sign character, whitespace, or non-ASCII codepoint is permitted. Decryptors MUST reject any integer field whose value does not match UINT.

4.1. Notation

This document uses the following notation:

Table 3
Symbol Meaning
|| Octet string concatenation
XOR Bitwise exclusive-or of equal-length octet strings
len(x) Length of x in octets
x[i:j] Slice of x from octet i (inclusive) to j (exclusive), zero-indexed
uint8(n) 8-bit unsigned integer n (single octet)
I2OSP(n, w) w-octet big-endian encoding of non-negative integer n
frame(x) 2-octet length-prefixed framing of x; total over all field lengths via a reserved 0xFFFF digest prefix (Section 4.1.1)
Encode(x1, ..., xn) frame(x1) || ... || frame(xn), the multi-value length-prefixed encoding
...x List expansion: if x is a list, each element becomes a separate argument; if x is a single octet string, it is passed as one argument
uint64(n) 64-bit unsigned integer n in network byte order (big-endian)
floor(x) Largest integer less than or equal to x
ceil(x) Smallest integer greater than or equal to x

All integers serialized in binary are unsigned and use network byte order (big-endian). Multi-byte integer fields are serialized most-significant byte first.

4.1.1. Length-Prefixed Framing

SAFE encodes its structured inputs with the length-prefixed framing of Section 4.3 (The KDF Combiner) of [I-D.sullivan-cfrg-raae]. frame emits a field of at most 0xFFFE octets literally behind a 2-octet length, the reserved length 0xFFFF introduces a fixed-size digest of an over-large field, and Encode concatenates the framed elements. SAFE uses this framing unchanged, so the key identifier (Section 5.6.3.3) works for a public key of any size: frame of the SPKI DER encoding takes the digest form automatically once it exceeds 0xFFFE octets.

A 0xFFFF entry is bind-only: it commits to the field but does not carry it. The SAFE KEM Identifiers registry (Section 10.2) and the SAFE Step Names registry (Section 10.4) MUST mark which fields a decoder must RECOVER from the encoding. Every recoverable field MUST be at most 0xFFFE octets so that it is always encoded literally; the set of recoverable fields is the closed set named by those registries, not an open-ended example. The recoverable fields defined today are the KEM ciphertext carried in a step_descriptor (Section 5.6.3) and the step parameters parsed from the descriptor. All KEM ciphertexts for registered KEMs (Section 5.6.3.2) satisfy this bound. A decoder that reads a 0xFFFF length prefix where a recoverable field is expected MUST treat it as a parse error and reject the input; it MUST NOT accept the entry as a bind-only commitment in that position.

4.2. Text Encoding

SAFE header lines (fence markers, field names, field values) MUST contain only ASCII printable characters (0x20-0x7E) plus LF (0x0A). Derive info strings and AEAD AAD prefixes use ASCII. Decryptors MUST reject malformed UTF-8 in text fields.

Base64 encoding follows the rules in Section 4. Base64 values in headers MAY wrap across lines; continuation lines MUST begin with at least one space or tab. Decryptors MUST strip leading whitespace from continuation lines before concatenating and decoding.

Encryptors MUST use LF (0x0A) line terminators. Decryptors MUST accept LF and MAY accept CRLF. Decryptors MUST strip trailing whitespace from header lines.

Case sensitivity:

All field names, identifiers, and fence markers are case-sensitive.

4.3. Terminology

CEK (Content-Encryption Key):

A randomly generated 32-octet key used to derive the payload encryption key. The CEK is wrapped independently for each recipient.

KEK (Key-Encryption Key):

A 32-octet key derived from a LOCK's step sequence. Used to wrap or unwrap the CEK.

Encryptor:

The party that creates a SAFE-encoded file.

Decryptor:

The party that recovers plaintext from a SAFE-encoded file using appropriate credentials.

5. Algorithms

5.1. Default Parameters

The following defaults apply whenever a CONFIG block is absent or when a field is omitted from the CONFIG block:

Table 4
Field Default Value
Profile SEAL-editable
AEAD aes-256-gcm
KDF hkdf-sha-256
Lock-Encoding armored
Data-Encoding binary

Implementations MUST use these values for any omitted fields. CONFIG need only include fields that differ from the defaults; see Section 6.1.

5.2. Algorithm Summary Tables

This section provides a quick reference of all cryptographic algorithms and identifiers used in SAFE. Detailed specifications appear in later sections.

5.2.1. AEAD Algorithms

Table 5
Algorithm Identifier Nk Nn MRAE
AES-256-GCM aes-256-gcm 32 12 No
ChaCha20-Poly1305 chacha20-poly1305 32 12 No
AES-256-GCM-SIV aes-256-gcm-siv 32 12 Yes
AEGIS-256 aegis-256 32 32 No
AEGIS-256X2 aegis-256x2 32 32 No

Nk/Nn are key/nonce sizes in octets. "MRAE" indicates misuse-resistant authenticated encryption ([RFC9771]; see Section 8.10). Epoch length is not a CONFIG choice: the selected SEAL named instantiation resolves it (Section 5.8.1, Section 5.8.3). AEADs without MRAE permit plaintext recovery under nonce reuse; Encryptors SHOULD select an MRAE AEAD when nonce reuse cannot be ruled out. All AEADs provide [RFC5116] semantics with 16-octet tags. In every SAFE DATA payload a 32-octet salt leads the raAE header (Section 6.4), followed by an Nh-octet commitment prefix derived per Section 5.8.2 that binds the ciphertext to the CEK, the negotiated algorithm parameters, and the per-file salt (see Section 8.13).

5.2.2. Key Encapsulation Mechanisms

Table 6
KEM Identifier HPKE KEM ID Encap Size Auth
X25519 x25519 0x0020 32 octets Yes
P-256 p-256 0x0010 65 octets Yes
ML-KEM-768 ml-kem-768 0x0041 1088 octets No

HPKE KEM IDs are defined in Section 7.1 of [RFC9180] and the IANA HPKE KEM Identifiers registry. ML-KEM-768 enables hybrid post-quantum constructions via multi-step step sequences (see Section 5.6.3.2).

All KEMs use HPKE [RFC9180] in export-only mode (AEAD ID 0xFFFF). The encryptor calls SetupBaseS (or SetupAuthS when sid or shint is present) to produce a KEM ciphertext and an HPKE context, then calls Export on the context to derive the step secret (Section 5.6.3.4). When a sender parameter is present, HPKE Auth mode is used (see Section 5.6.3.1). The KEM identifier appears in hpke(...) step descriptors (Section 5.6.3). SAFE maintains a registry mapping string identifiers to HPKE KEM IDs (Section 10.2).

5.2.3. Step Types

Table 7
Step Type Token Format Parameters Secret
Passphrase pass(kdf=..., salt=...) kdf, salt 32 octets
HPKE hpke(kem=X, kemct=..., ...) kem, kemct, id/hint, sid/shint 32 octets

Step types are composed via multiple Step lines within a LOCK block, with AND semantics: all steps are required to derive the KEK. Each step conforms to the interface defined in Section 5.6 and produces a 32-octet secret that contributes to KEK derivation (Section 5.7.1). Additional step types MAY be registered per Section 10.4.

5.3. Algorithm Requirements

AEADs used with SAFE MUST provide [RFC5116] semantics with a 16-octet authentication tag. KEMs MUST use HPKE export-only mode (AEAD ID 0xFFFF) as specified in Section 5.6.3. When sid or shint is present, HPKE Auth mode is used (Section 5.6.3.1).

5.4. Key Derivation Function

SAFE piggybacks on the HPKE KDF interface. HPKE [RFC9180] and [I-D.ietf-hpke-pq] define two KDF classes:

Two-stage KDFs (e.g., HKDF-SHA256, KDF ID 0x0001):

  • KDF.Nh: output size of Extract (32 for HKDF-SHA256)

  • KDF.Extract(salt, ikm) -> prk (Nh octets)

  • KDF.Expand(prk, info, L) -> okm (L octets)

Single-stage KDFs (e.g., TurboSHAKE256, [I-D.ietf-hpke-pq]):

  • KDF.Derive(ikm, L) -> okm (L octets). For TurboSHAKE256: TurboSHAKE256(M=ikm, D=0x1F, L).

The KDF config parameter selects the key derivation function and its class. Any KDF registered for SAFE MUST be registered in the HPKE KDF Identifiers registry [RFC9180] and MUST implement either the two-stage (Extract/Expand/Nh) or single-stage (Derive) interface.

5.4.1. SafeDerive

SafeDerive is the universal combiner of Section 4.3 (The KDF Combiner) of [I-D.sullivan-cfrg-raae], instantiated at protocol_id "SAFE-v1". SafeDerive(label, ikm, info, L) binds the protocol version, a call-site label, and the ikm and info inputs into an L-octet output. ikm and info each accept a single octet string or an ordered list, and the combiner frames each as one element, so any number of inputs of any size is admitted. Callers that require suite binding include the suite parameters in info. The key identifier (Section 5.6.3.3) omits them, but it is not config-independent. SafeDerive invokes the configured KDF to produce the identifier, so the identifier is bound to that KDF.

The key identifier (Section 5.6.3.3) and the per-LOCK key (Section 5.7.1) use SafeDerive. SealDerive is the same combiner at the payload protocol_id, which is SEAL-RW-v1 for the mutable instantiations and SEAL-RO-v1 for the immutable ones, selected by the Profile (Section 5.8.1). The payload layer (Section 5.8.2) adopts the selected SEAL named instantiation of [I-D.sullivan-cfrg-raae] verbatim.

SafeDerive labels MUST be unique across all call sites within a single protocol version. The labels defined by this specification are reserved; future extensions and step type registrations MUST NOT reuse them. Similarly, SafeRandom labels (Section 5.4.2) MUST be unique and MUST NOT be reused by extensions.

5.4.2. Random Generation (SafeRandom)

SafeRandom is the random generation function used for all encryptor-generated random values in SAFE. It requires a cryptographically secure pseudorandom number generator (CSPRNG).

5.4.2.1. Base Construction
SafeRandom(n, label):
    return CSPRNG(n)

When no private key is available, SafeRandom returns raw CSPRNG output. The label parameter is used for domain separation when hedging is enabled (Section 5.4.2.2); it is ignored in the base construction but callers MUST still supply it. This invariant keeps call sites consistent across deployments: if a hedge key becomes available later, the binding is correct without requiring code changes at every SafeRandom call site.

5.4.2.2. Hedged Construction

When a long-term private key sk is available, the Encryptor SHOULD mix it into SafeRandom to defend against a weak or attacker-influenced RNG. The construction follows [RFC8937]: hedge_key is a deterministic function of sk (replacing the signature in [RFC8937] with a KDF, since SAFE does not require a signature scheme), and SafeRandom combines it with CSPRNG output via SafeDerive.

hedge_key = SafeDerive("SAFE-HEDGE", sk, "", 32)

SafeRandom(n, label):
    return SafeDerive(label,
        [hedge_key, CSPRNG(n)], "", n)

An adversary who can predict CSPRNG output but does not know sk cannot predict the hedged values. Hedging does not prevent repeated output from RNG state duplication (VM snapshot restore, process fork without reseed); identical CSPRNG output produces identical hedged output regardless of the private key.

Suitable private keys include any long-term key held by the Encryptor (an HPKE sender private key, an application-provided signing key, or similar). The key need not correspond to any LOCK step.

Encryptors MUST use SafeRandom for all random values generated during SAFE encoding, each under its own reserved label: the CEK (SafeRandom(32, "SAFE-CEK")), the per-file payload salt (SafeRandom(32, "SAFE-SALT")), the per-step passphrase salt (SafeRandom(16, "SAFE-PASS-SALT")), lock_nonce (SafeRandom(Nn, "SAFE-LOCK-NONCE")), and per-segment nonces in random mode (SafeRandom(Nn, "SAFE-NONCE")). HPKE internal randomness (Encap) is not hedged by default. Implementations whose HPKE library accepts an external randomness source SHOULD supply SafeRandom(Nrand, "SAFE-ENCAP") instead of raw CSPRNG output, where Nrand is the randomness length required by the KEM.

A functioning CSPRNG is REQUIRED when no private key is available. See Section 8.10 for the security analysis.

5.5. Encryption Parameters

The encryption_parameters input is an ordered list of five elements derived from the effective parameters (defaults augmented by any config overrides). The aead_id, kdf_id, and snap_id are 2-octet big-endian numeric identifiers, segment_size_be is a 4-octet integer, and epoch_length_u8 is a single octet. It never appears on the wire; Encryptors and Decryptors compute it from the effective parameters. The readable CONFIG tokens are unchanged and appear only in CONFIG.

encryption_parameters = [aead_id, segment_size_be, kdf_id, snap_id,
                         epoch_length_u8]
aead_id

I2OSP(code point, 2): the 2-octet big-endian AEAD ID of the effective AEAD parameter, taken from the AEAD ID column of Section 10.1.

segment_size_be

I2OSP(Segment-Size, 4): the effective Segment-Size as a 4-octet big-endian integer.

kdf_id

I2OSP(code point, 2): the 2-octet big-endian HPKE KDF ID of the effective KDF parameter, taken from the HPKE KDF ID column of Section 10.3. The KDF ID identifies the construction (HKDF [RFC5869] with the selected hash, or a single-stage KDF).

snap_id

I2OSP(code point, 2): the 2-octet big-endian snapshot-authenticator identifier fixed by the Profile (Section 5.8.1). 0x0000 is no snapshot; the other values name an authenticator registered in Section 4.7 of [I-D.sullivan-cfrg-raae].

epoch_length_u8

I2OSP(r, 1) where r is the epoch_length the selected SEAL named instantiation resolves to (Section 4.12 of [I-D.sullivan-cfrg-raae]), including that document's treatment of the 256-bit-nonce suites; see Section 5.8.3.

The list always has exactly five elements. The aead_id and kdf_id MUST equal the registered code points (Section 10.1 and the HPKE KDF registry Section 10.3). The snap_id MUST be the one the Profile fixes (Section 5.8.1). Because the Profile, AEAD, and KDF fix these elements, changing any of them requires re-encryption of the payload and re-wrapping of the CEK in new LOCKs.

SafeDerive splices this list via ...encryption_parameters; each element is individually framed (Section 4.1.1).

All algorithm and step identifiers in CONFIG and LOCKs are case-sensitive ASCII strings. Implementations MUST compare these identifiers byte-exact. Case-insensitive comparison, Unicode normalization, and whitespace trimming MUST NOT be applied to identifier values before comparison. The numeric identifiers in encryption_parameters (aead_id, kdf_id, snap_id) compare as the integers they encode.

SafeDerive binds encryption_parameters in the LOCK's single KEK derivation (Section 5.7.1). The payload schedule binds a distinct input, payload_info (Section 5.8.2): the SEAL list that additionally carries nonce_mode and the per-file salt. The two lists diverge at nonce_mode (Section 5.8.2). See the worked example in Appendix G.

5.6. Steps

Step terminology at a glance:

Step: <type>(<key>=<val>, ...) step_descriptor (on-wire form) step type step parameters step_binding step_secret (Encode) (32 octets)

5.6.1. Step Interface

A step is a registered cryptographic operation that produces a 32-octet step secret from user-supplied credentials or cryptographic material. Each step type MUST define:

Step name:

A unique ASCII identifier used in step descriptors (e.g., "pass", "hpke").

Parameters:

An ABNF grammar for step-specific parameters appearing in the token (e.g., kem=x25519, id=...).

Derivation:

A deterministic algorithm that produces exactly 32 octets. The algorithm MUST be reproducible given the same inputs. The step definition MUST specify all required inputs for both encryption and decryption.

KEK schedule integration:

The step secret and step_binding feed into the LOCK's single KEK derivation (Section 5.7.1). The ordered step secrets form the ikm argument to SafeDerive, and the encryption parameters followed by the ordered step bindings form the info argument. SafeDerive maps these logical inputs to the selected SAFE KDF class. Each step has two encodings: the step_descriptor is the on-wire form that appears verbatim in the LOCK block; the step_binding is the canonical binary encoding of its cryptographically relevant fields via Encode() (Section 4.1) and serves as the binding form in the KEK schedule. Display-only fields (label, hint, shint) are excluded from the step_binding. The step_bindings for the built-in step types are:

Table 8
Step On-wire descriptor step_binding
pass pass(kdf, salt) Encode("pass", kdf, salt)
hpke hpke(kem, kemct, [id/hint]) Encode("hpke", kem, kemct, id)
hpke (auth) hpke(kem, kemct, [id/hint], [sid/shint]) Encode("hpke", kem, kemct, id, "auth", sid)

String values (kdf, kem) are UTF-8; binary values (salt, kemct, id, sid) are raw decoded octets, not Base64.

Fields computed for binding (hpke id, hpke sid, webauthn-prf rpid) may be omitted on-wire for privacy but are deterministically reconstructed during decryption and always appear in the step_binding. For hpke, id is always present in the step_binding even when omitted from the on-wire descriptor; it is computed during trial decryption. Similarly, sid is optional on-wire but always present in auth-mode binding; when omitted, it is computed from the candidate sender public key.

Registration:

New step types are registered via the IANA SAFE Step Names registry (Section 10.4) with Specification Required policy. The registration MUST include: step name, parameters grammar, inputs, derivation algorithm, Encode binding form, and any step-specific parameter definitions. See Appendix I for an example.

label (OPTIONAL, any step):

A human-readable display name intended to help users identify which passphrase, credential, or key to use during decryption (e.g., "Work laptop", "Recovery key"). The label is always excluded from the step_binding and has no cryptographic effect. Encryptors MAY include a label in any step descriptor; Decryptors MUST ignore it for binding purposes. The label value MUST match the grammar 1*(ALPHA / DIGIT / "-").

Steps are registered in the IANA SAFE Step Names registry (Section 10.4). The following subsections define the initial registered steps.

5.6.2. Passphrase Step

The passphrase step derives a 32-octet step secret from a user passphrase using a password-based KDF. The kdf parameter selects the algorithm:

Table 9
KDF Algorithm Parameters
argon2id Argon2id [RFC9106] m=65536 KiB, t=2, p=1
pbkdf2 PBKDF2-HMAC-SHA-256 [RFC8018] iter=600000

The step descriptor format is:

pass(kdf=<kdf>,salt=<Base64>)
pass(kdf=<kdf>,salt=<Base64>,label=<text>)

The kdf and salt parameters are REQUIRED. The label parameter is OPTIONAL and is for display only; it is not included in the step_binding (Section 5.6). Encryptors MUST generate a fresh 16-octet salt using SafeRandom(16, "SAFE-PASS-SALT") for each pass(...) step in a LOCK. Decryptors MUST reject pass(...) steps whose salt value does not decode to exactly 16 octets.

Grammar:

pass-step   = "pass(" pass-params ")"
pass-params = "kdf=" kdf-name "," "salt=" salt
              [ "," "label=" label-value ]
kdf-name    = "argon2id" / "pbkdf2"
salt        = 1*BASE64CHAR
label-value = 1*( ALPHA / DIGIT / "-" )

Encode form:

Encode("pass", kdf, salt)

step_binding: Encode("pass", kdf, salt).

The step secret is computed as follows:

For kdf=argon2id:

Argon2id(passphrase, salt, m=65536, t=2, p=1, T=32) per Section 3.1 of [RFC9106].

For kdf=pbkdf2:

PBKDF2(PRF=HMAC-SHA-256, Password=passphrase, Salt=salt, c=600000, dkLen=32).

In both cases, salt is the decoded value of the salt parameter.

Implementations SHOULD prefer argon2id for its memory-hardness properties. Implementations MAY support pbkdf2 for environments where Argon2id is not permitted by policy.

5.6.3. HPKE Step

The HPKE step descriptor has three forms:

hpke(kem=x25519, kemct=<Base64>, id=<Base64>)    ; Identified mode
hpke(kem=x25519, kemct=<Base64>, hint=<digits>)  ; Hinted mode
hpke(kem=x25519, kemct=<Base64>)                 ; Anonymous mode

The parameters are:

kem (REQUIRED):

The KEM algorithm. Supported values: x25519, p-256, ml-kem-768.

kemct (REQUIRED):

The Base64-encoded HPKE KEM encapsulated key material (the KEM ciphertext). This value MUST decode to the encapsulated key length for the selected KEM (see Section 5.2.2). Decryptors MUST reject hpke steps whose decoded kemct length does not match the KEM's encapsulated key length.

id (OPTIONAL):

The key identifier computed as SafeDerive("SAFE-SPKI-v1", spki_der, "", 32) using the configured KDF (default: hkdf-sha-256). When present, Decryptors match this value against their local keys. When omitted, Decryptors perform trial decryption. See Section 5.6.3.3 and Section 8.7.

hint (OPTIONAL):

A 4-digit decimal value (0000-9999) assigned by the recipient out-of-band; not solely dependent on the public key. When present, Decryptors filter candidate keys to those associated with this hint in their local key storage. Mutually exclusive with id.

Encryptors MUST include exactly one of: id, hint, or neither (but not both id and hint).

5.6.3.1. HPKE Auth Mode

In Base mode, any party who knows a recipient's public key can create a valid SAFE object for that recipient. Auth mode [RFC9180] uses SetupAuthS/SetupAuthR, which bind the HPKE context to the sender's private key so that the Decryptor can verify who produced the object. This is useful for offline encrypted file exchange where the recipient needs assurance of origin (for example, encrypted firmware images, signed-then-encrypted document workflows, or air-gapped key escrow) without requiring a separate signature layer.

The presence of sid or shint selects HPKE Auth mode (mode_auth) instead of Base mode. Auth mode MUST only be used with Diffie-Hellman KEM (DHKEM) based KEMs (x25519, p-256). Encryptors MUST NOT include sid or shint with ml-kem-768 or other non-DHKEM KEMs, because these KEMs do not define AuthEncap/AuthDecap.

sid (OPTIONAL):

The sender's key identifier, computed as SafeDerive("SAFE-SPKI-v1", spki_der, "", 32) using the same KDF as id. When present with a Base64 value, Decryptors match it against known sender public keys. The special value anon indicates anonymous sender Auth mode: the sender's key is not identified, and Decryptors perform trial decryption across candidate sender keys. Mutually exclusive with shint.

shint (OPTIONAL):

A 4-digit decimal value (0000-9999) assigned by the sender out-of-band; parallels hint for recipient keys. When present, Decryptors filter candidate sender keys to those associated with this value. Mutually exclusive with sid.

Encryptors MUST include exactly one of sid or shint (but not both) when using Auth mode.

Auth mode token forms extend the base forms:

hpke(kem=x25519, kemct=<B64>, id=<B64>, sid=<B64>)
hpke(kem=x25519, kemct=<B64>, sid=<B64>)
hpke(kem=x25519, kemct=<B64>, sid=anon)
hpke(kem=x25519, kemct=<B64>, shint=1234)

All combinations of recipient identification (id, hint, or anonymous) and sender identification (sid, shint, or sid=anon) are valid.

The hpke step descriptor refines the step-descriptor grammar in Section 6.2.1.1. OWS (optional whitespace) is permitted after each comma separator, consistent with step-params:

hpke-step    = "hpke(" hpke-params ")"
hpke-params  = "kem=" kem-name "," OWS "kemct=" kemct
               [ "," OWS recipient-id ]
               [ "," OWS sender-id ]
kem-name     = 1*( ALPHA / DIGIT / "-" )
                 ; registered in SAFE KEM Identifiers
                 ; registry ({{iana-kem}})
kemct        = 1*BASE64CHAR
recipient-id = "id=" 1*BASE64CHAR / "hint=" hint-value
sender-id    = "sid=" ( 1*BASE64CHAR / "anon" )
             / "shint=" hint-value
hint-value   = 4DIGIT

Encryptors MUST NOT include both id and hint, and MUST NOT include both sid and shint.

Each HPKE step uses HPKE [RFC9180] in export-only mode with ciphersuite (KEM_ID, KDF_ID, 0xFFFF) constructed from the KEM's registered identifiers (Section 5.6.3.2). AEAD ID 0xFFFF disables Seal/Open; only Export is used.

For Base mode (default):

;; Encryptor
(kemct, ctx) = SetupBaseS(pkR, info="SAFE-v1")
step_secret  = ctx.Export(exporter_context, L=32)

;; Decryptor
ctx          = SetupBaseR(kemct, skR, info="SAFE-v1")
step_secret  = ctx.Export(exporter_context, L=32)

For Auth mode (sid or shint present):

;; Encryptor
(kemct, ctx) = SetupAuthS(pkR, skS, info="SAFE-v1")
step_secret  = ctx.Export(exporter_context, L=32)

;; Decryptor
ctx          = SetupAuthR(kemct, skR, pkS, info="SAFE-v1")
step_secret  = ctx.Export(exporter_context, L=32)

The kemct value is the KEM ciphertext (enc in HPKE terminology). The exporter_context is defined in Section 5.6.3.4.

This design uses HPKE's standardized key schedule and export interface for KDF agility, while SAFE's own SafeDerive function handles KEK derivation, payload key derivation, and nonce constructions.

5.6.3.2. Supported KEMs

The following table lists the KEMs defined in the IANA HPKE KEM Identifiers registry [RFC9180] that are recognized by SAFE:

Table 10
KEM KEM ID KDF ID HPKE Ciphersuite Key Encoding
x25519 0x0020 (see below) (0x0020, KDF_ID, 0xFFFF) [RFC8410]
p-256 0x0010 (see below) (0x0010, KDF_ID, 0xFFFF) [RFC5480]
ml-kem-768 0x0041 (see below) (0x0041, KDF_ID, 0xFFFF) (see below)

The HPKE Ciphersuite column shows the (KEM_ID, KDF_ID, AEAD_ID) triple used with HPKE's Setup functions. AEAD ID 0xFFFF selects export-only mode per Section 5.3 of [RFC9180].

  • All KEMs, including DHKEM-based KEMs (x25519, p-256), use the configured KDF, both for the HPKE key schedule and for DHKEM's internal operations per [I-D.ietf-hpke-hpke]: HKDF-SHA256 (KDF ID 0x0001) when KDF=hkdf-sha-256, or TurboSHAKE256 (KDF ID 0x0013) when KDF=turboshake256.

  • When KDF=turboshake256, the HPKE implementation MUST conform to the one-stage key schedule defined in [I-D.ietf-hpke-hpke].

ML-KEM-768 key encoding follows [I-D.ietf-lamps-kyber-certificates]. Auth mode requires AuthEncap/AuthDecap, which are defined only for DHKEM-based KEMs. ML-KEM-768 MUST NOT be used with Auth mode. Additional KEMs from the IANA HPKE KEM Identifiers registry MAY be supported following the process defined in Section 10.2.

5.6.3.3. Key Identifier Computation

The id parameter in hpke(...) steps identifies the intended recipient public key. Key identifiers hash the SubjectPublicKeyInfo (SPKI) Distinguished Encoding Rules (DER) encoding rather than raw key octets. This ensures key identifiers are consistent with certificate fingerprint practices and include the algorithm Object Identifier (OID), preventing collisions between keys of different types.

spki_der = DER-encode SubjectPublicKeyInfo for pk
           per the KEM's registered SPKI Encoding ({{iana-kem}})
           DER encoding MUST be canonical.

id = Base64( SafeDerive("SAFE-SPKI-v1",
    spki_der, "", 32) ) per {{RFC4648}}

The resulting Base64 string is the value of the id parameter (44 characters for the 32-octet output).

5.6.3.4. HPKE Step Secret Derivation

When the step sequence includes one or more hpke(...) steps, the LOCK MUST include a corresponding kemct parameter value for each HPKE step, in the same order as they appear in the step sequence. Encryptors MUST generate a fresh encapsulation per LOCK; reusing a prior encapsulation is prohibited.

For Auth mode, the Decryptor resolves the sender public key as follows:

  • If sid is present and is not anon: match against known sender public keys using the configured KDF.

  • If shint is present: filter candidate sender keys by the hint value.

  • If sid=anon: try all locally known sender keys matching the kem type.

The step secret is derived via HPKE's Export interface:

exporter_context = SafeDerive("SAFE-STEP",
    step_binding, "", 32)

step_secret = ctx.Export(exporter_context, L=32)

where ctx is the HPKE context returned by SetupBaseS/R (or SetupAuthS/R for Auth mode) as described in Section 5.6.3.2, and step_binding is the binding form defined in Section 5.6.

When id or sid is omitted from the on-wire token, the Decryptor reconstructs it during trial decryption per Section 5.6.

The KEM binds the shared secret to the recipient key (and for Auth mode, the sender key). The exporter_context binds the step secret to the step_binding, preventing key-confusion attacks where an attacker substitutes one recipient's encapsulation for another's. The HPKE info parameter "SAFE-v1" binds the key schedule to this protocol, preventing cross-protocol reuse of the same KEM keys from producing valid SAFE step secrets. Suite binding is not needed here because the final KEK derivation commits to encryption_parameters (Section 5.7.1).

Encode form:

Encode("hpke", kem, kemct, id)
Encode("hpke", kem, kemct, id,
       "auth", sid)                    ; auth

step_binding: Encode("hpke", kem, kemct, id) for Base mode; Encode("hpke", kem, kemct, id, "auth", sid) for Auth mode. Display-only fields (hint, shint) are not included. The id and sid fields are reconstructed per Section 5.6 when omitted on-wire.

5.7. Key Schedules

SAFE uses two applications of SafeDerive (Section 5.4). The KEK derivation, specified in this section, produces a LOCK-specific KEK from its ordered step secrets. The payload derivation (Section 5.8) produces the per-file payload key, commitment, snapshot authenticator key, and (in derived mode) nonce base from the CEK, encryption parameters, and per-file salt.

The following diagram shows the two independent chains:

CEK (32 octets) (sealed by) (derives from) KEK (per LOCK) Payload chain (per file) derived_kek = payload_info = SafeDerive( SEAL 7-element list "lock-kek", [step_secret_1, commitment = ..., _N], SealDerive("commit", [...enc_params, CEK, step_binding_1, [...payload_info, ..., _N], G], 32) Nk) payload_key = SealDerive( one SafeDerive "payload_key", CEK, call binds every payload_info, Nk) step: a LOCK is the AND of its nonce_base = steps (no running SealDerive( aggregator) "nonce_base", CEK, payload_info Nn) (derived mode) | snap_key = Encrypted-CEK = SealDerive( lock_nonce || "acc_key", CEK, AEAD.Seal(derived_kek, payload_info, Nh) lock_nonce, "", CEK)

Payload encryption is performed once under the CEK and per-file salt and does not depend on lock structure. Locks are independent wrappers of the same CEK and can be added or removed without touching payload ciphertext.

5.7.1. LOCK Key Derivation

The LOCK key derivation produces a LOCK's KEK from its ordered step secrets in a single call to the universal combiner (Section 5.4):

Algorithm:

derived_kek = SafeDerive("lock-kek",
                  [step_secret_1, ..., step_secret_N],
                  [...encryption_parameters,
                   step_binding_1, ..., step_binding_N],
                  Nk)

The ordered step secrets form the ikm argument to SafeDerive, and the encryption parameters followed by the ordered step bindings form the info argument. The combiner frames each as one element (Section 5.4.1). A LOCK is the AND of its steps: every step secret and step binding is bound in this one derivation, and the encryption_parameters commit it to the negotiated AEAD, segment size, KDF, snap_id, and epoch length. Step order is fixed by position in the ikm and info arrays, so steps cannot be reordered. Appendix G.6 gives worked vectors for step-order binding, display-only-field exclusion, and bound-field sensitivity.

Within a LOCK, all Steps are required: derived_kek depends on every step secret, so the holder must satisfy every Step (a logical AND). A file MAY carry several LOCKs as independent alternatives; satisfying any one LOCK yields the CEK (a logical OR). Each LOCK wraps the same CEK (Section 5.7.2), so LOCKs can be added or removed without re-encrypting the payload.

LOCK 1 (pass AND hpke) LOCK KDF KEK_1 LOCK 2 (recovery) LOCK KDF KEK_2 unwrap CEK LOCK 3 (admin) LOCK KDF KEK_3

The LOCK KDF is a single SafeDerive call. The SAFE KDF selected by the CONFIG KDF parameter (Section 5.4) determines how it is evaluated: a two-stage KDF (HKDF-SHA-256) runs Extract then Expand, while a single-stage KDF (TurboSHAKE256) runs Derive. The binding inputs and the secret inputs are identical in either case, so the KDF is a swappable component.

step secrets + step bindings + encryption_parameters LOCK KDF HKDF-SHA-256 TurboSHAKE256 future SAFE KDF (Extract then (single-stage (registered Expand) Derive) class)
5.7.1.1. Multi-Step Example

Consider a LOCK block with two steps requiring both a passphrase and a private key:

Step: pass(kdf=argon2id, salt=<Base64>)
Step: hpke(kem=p-256, id=<Base64>, kemct=<Base64>)
Encrypted-CEK: <Base64>

Evaluation proceeds per Section 5.7.1:

// Step 1: passphrase
step_secret_1  = Argon2id(passphrase, salt)

// Step 2: HPKE (export-only mode)
ctx            = SetupBaseR(kemct, sk, info="SAFE-v1")
step_secret_2  = ctx.Export(
    exporter_context =
        SafeDerive("SAFE-STEP",
            step_binding_2, "", 32),
    L = 32)

// One combiner call over both steps, in order
derived_kek = SafeDerive("lock-kek",
                  [step_secret_1, step_secret_2],
                  [...encryption_parameters,
                   step_binding_1, step_binding_2],
                  Nk)

The derived_kek depends on both factors. Each step is bound to its position in the ikm and info arrays, preventing step reordering.

5.7.2. Sealing Encrypted-CEK

The per-step salt and kemct values MUST be unique per LOCK. Reusing these values with the same credentials produces the same step_secret, weakening KEK uniqueness.

With derived_kek computed per Section 5.7.1, the Encrypted-CEK field is:

Encrypted-CEK = lock_nonce || AEAD.Seal(
    key   = derived_kek,
    nonce = lock_nonce,
    aad   = "",
    pt    = CEK )

The AEAD algorithm is the one specified in the CONFIG block (or the default aes-256-gcm when CONFIG is absent); the same AEAD is used for both CEK wrapping and payload encryption. Empty AAD is sufficient because derived_kek already binds the encryption_parameters and all step_bindings in its single derivation (Section 5.7.1), so repeating this binding in the AEAD AAD would be redundant.

Encryptors MUST generate lock_nonce using SafeRandom(Nn, "SAFE-LOCK-NONCE"); each LOCK requires a fresh value.

5.8. Payload Layer (SEAL)

This section specifies the payload layer: the SEAL named instantiation SAFE adopts and its bindings, the payload key schedule, per-segment nonces, rewrite rules, the snapshot authenticator, and the segment AAD.

5.8.1. SEAL Profiles

SAFE's payload layer is SEAL, the construction for building random-access AEAD (raAE) schemes ([I-D.sullivan-cfrg-raae]). The Profile CONFIG field (Section 6.1) names one of the SEAL named instantiations in Table 11 (Section 4.12 of [I-D.sullivan-cfrg-raae]), by that instantiation's own name. Each instantiation fixes the SEAL profile (SEAL-RW-v1 mutable or SEAL-RO-v1 immutable), the segment size, the nonce mode, the snapshot authenticator, the epoch length, and the on-disk layout; the AEAD and KDF CONFIG fields supply the cipher suite. The payload layer derives in the selected SEAL domain, so that instantiation's SEAL vectors apply to it directly. The SAFE key layer (Section 5.7.1) remains in the SAFE-v1 domain. SAFE supplies no global associated data, so SEAL's G input is the empty octet string for every SAFE object. G is always the last element of the commitment info, one zero-length element, so every commitment derivation includes it (Section 4.5.1 of [I-D.sullivan-cfrg-raae]). The payload key, snap key, and nonce base never take G.

Implementations MUST implement the payload layer as the selected SEAL named instantiation of [I-D.sullivan-cfrg-raae], using the SEAL parameter bindings given later in this section.

Table 11: SAFE Profiles and their raAE instantiations
Profile protocol_id Segment Nonce Snapshot Epoch Layout
SEAL-simple SEAL-RO-v1 65536 derived 0x0000 32 linear
SEAL-attachment SEAL-RO-v1 65536 derived 0x0003 10 aligned
SEAL-attachment-small SEAL-RO-v1 65536 derived 0x0002 10 aligned
SEAL-editable SEAL-RW-v1 65536 random 0x0001 16 linear
SEAL-memory SEAL-RW-v1 16384 random 0x0001 16 aligned
SEAL-compact SEAL-RW-v1 16384 derived 0x0001 16 aligned

The parameter columns above are informative. Section 4.12 of [I-D.sullivan-cfrg-raae] is authoritative for every value in them, including its treatment of the 256-bit-nonce suites, which resolve the epoch independently of the row. An implementation MUST take these values from that document. Only the Profile column, the CONFIG token, is SAFE's.

A row applies its cipher suite to its instantiation, written SEAL-simple(aead_id, kdf_id) and so on (Section 4.12 of [I-D.sullivan-cfrg-raae]). SAFE adopts each instantiation's pinned epoch length rather than the cipher suite's own default (Section 5.9.6 of [I-D.sullivan-cfrg-raae]): the write-once SEAL-simple instantiation takes 32, the write-once SEAL-attachment takes 10 for the epoch digest tree's grouping and SEAL-attachment-small takes the same for its aligned per-segment use case, and the three mutable instantiations take a conservative 16. Those are decimal values, which epoch_length_u8 encodes as I2OSP(r, 1) (Section 5.5), so an epoch length of 10 is the octet 0x0A. Section 4.12 of [I-D.sullivan-cfrg-raae] resolves the epoch in every case, including for the 256-bit-nonce suites, which it treats independently of the row. SAFE does not exercise the epoch override that Section 4.12 of [I-D.sullivan-cfrg-raae] permits. SAFE registers six of raAE's seven instantiations. It does not register SEAL-disk, whose split layout stores two independently growing streams that do not map onto the single-file SAFE format. A future revision may add it.

Table 12: SEAL Profile Parameters
SEAL parameter SAFE binding
protocol_id "SEAL-RW-v1" (mutable) or "SEAL-RO-v1" (immutable), per the Profile (Section 6.1)
aad_label "SEAL-DATA"
aead_id uint16 code point of the effective AEAD (Section 10.1; e.g. 0x0002 for aes-256-gcm)
segment_max the effective Segment-Size (16384 or 65536), encoded as uint32
kdf_id uint16 code point of the effective KDF (Section 10.3; 0x0001 HKDF-SHA-256, 0x0013 TurboSHAKE-256)
snap_id snapshot authenticator, per the Profile (Section 5.8.1): 0x0000 none, or an authenticator registered in Section 4.7 of [I-D.sullivan-cfrg-raae]
nonce_mode fixed by the Profile (Section 6.1): 0x01 derived, 0x00 random
epoch_length the epoch the selected SEAL named instantiation resolves to (Section 5.8.1)
CEK output of the SAFE key layer (Section 5.7.1)
salt 32 octets, SafeRandom(32, "SAFE-SALT")
Nh the KDF output length (Section 10.3)
payload_info the seven-element list defined in Section 5.8.2

Each named instantiation is a valid (profile, nonce_mode, snap_id) combination by construction, so no separate tuple-validity check is needed; a Decryptor MUST reject any object whose Profile is not one SAFE defines (Section 6.1).

The mutable instantiations (SEAL-editable, SEAL-memory, SEAL-compact) support rewrite, extend, and truncate; SEAL-compact uses a derived nonce and so requires an MRAE AEAD, because rewrites reuse the derived nonce. The immutable instantiations (SEAL-simple, SEAL-attachment, SEAL-attachment-small) follow a write-once discipline and admit any AEAD. Every instantiation whose snap_id is not 0x0000 carries the snapshot authenticator (Section 5.8.7); SEAL-simple is the only one that does not, so it alone provides per-segment confidentiality, integrity, and key commitment without whole-object integrity. Here "immutable" names the writer's write-once discipline, not a tamper-evidence guarantee; an immutable object MUST NOT be rewritten, extended, or truncated.

Whole-object integrity, when required, comes from any instantiation whose snap_id is not 0x0000 (Section 5.8.1) or from a layer above SAFE.

Every instantiation provides per-segment confidentiality, per-segment integrity (binding index and finality), and key commitment. Every instantiation except SEAL-simple adds snapshot (whole-object) integrity. The full property statement is the Operational Summary (Section 5.1) of [I-D.sullivan-cfrg-raae].

No profile provides rollback protection: replaying an earlier valid snapshot or whole object is not detected, so freshness must be supplied by an external mechanism such as a monotonic version counter.

5.8.2. Payload Schedule

The payload schedule is the SEAL payload schedule (Section 4.5.1 of [I-D.sullivan-cfrg-raae], Payload Schedule) with the bindings of Section 5.8.1. SAFE's payload_info is the SEAL seven-element list (Section 4.4.1 of [I-D.sullivan-cfrg-raae], Payload Info Construction), in this exact order:

payload_info = [aead_id, segment_max_be, kdf_id, snap_id,
                nonce_mode, epoch_length_u8, salt]

Section 4.4.1 of [I-D.sullivan-cfrg-raae] gives each element's encoding. SAFE's values for them are the bindings of Section 5.8.1, which fixes protocol_id, snap_id, and the nonce mode from the Profile (Section 6.1).

payload_info is the SEAL payload_info verbatim. encryption_parameters (Section 5.5) is the five-element SAFE key-layer binding [aead_id, segment_size_be, kdf_id, snap_id, epoch_length_u8]. payload_info adds nonce_mode, between snap_id and epoch_length_u8, and the salt, so the two lists diverge at nonce_mode.

The encryption_parameters element segment_size_be and the payload_info element segment_max_be name the same value, the effective Segment-Size, in the SAFE key layer and the SEAL payload respectively.

The salt is stored with the payload, leading the raAE header (Section 6.4), so that even when the same CEK is reused across files each file produces unique schedule outputs.

The commitment is commitment_length octets, where commitment_length is Nh, the KDF output length (Section 10.3).

Before deriving or using any segment key, a Decryptor MUST re-derive the commitment from its own recovered CEK and its resolved payload_info and MUST reject the object if the derived commitment does not match the stored value (see Section 8.13).

The SAFE key layer binds snap_id and epoch_length_u8 through encryption_parameters (Section 5.5), but not protocol_id or nonce_mode. For those two the commitment check is the sole assurance that the Decryptor's resolved parameters match the ones the object was created under. SEAL-memory and SEAL-compact are the pair that turns on it: they agree on every encryption_parameters element and differ only at nonce_mode, so under a misuse-resistant AEAD, where both are legal, the commitment alone separates them, and it rejects before any plaintext is released. Any other confusion fails earlier, at the Encrypted-CEK unwrap: a wrong AEAD or KDF changes aead_id or kdf_id, and any other pair of Profiles differs in an element that encryption_parameters binds.

The following table summarizes the lifecycle of each payload-layer value: its type and size, whether it is secret, whether it appears on the wire, and when it is computed. The length symbols are:

Nh:

KDF output length (32 for HKDF-SHA-256, 64 for TurboSHAKE-256).

Nk:

AEAD key length.

Nn:

AEAD nonce length.

Na:

the snapshot value length for the resolved snap_id, and 0 when snap_id is 0x0000 (Section 4.7.1 of [I-D.sullivan-cfrg-raae]).

The snapshot value is present whenever the Profile's snap_id is not 0x0000 (Section 5.8.1). A Profile's snapshot and its write discipline are independent, and Section 4.10.2 of [I-D.sullivan-cfrg-raae] gives which authenticators each SEAL profile admits. Per-segment nonces are stored only in random mode (nonce_mode 0x00). In derived mode (nonce_mode 0x01) the nonce is recomputed and never stored.

Table 13: Payload-layer value lifecycle
Value Type / size Secret? On wire? When computed / lifetime
CEK AEAD key, Nk octets Yes No (wrapped per recipient as Encrypted-CEK in each LOCK) Sampled once per file by the Encryptor; recovered by a Decryptor via the KEK layer (Section 5.7.1); zeroized after use
salt 32 octets, SafeRandom(32, "SAFE-SALT") No Yes (leads the raAE header, Section 6.4) Sampled once per file; reused on append and file-extension; binds every payload schedule output to this file
commitment Nh octets, SealDerive("commit", CEK, [...payload_info, G], Nh) No Yes (immediately after salt) Derived once from CEK and payload_info with the empty G (Section 5.8.1); a Decryptor re-derives and MUST match it before any segment key derivation (Section 8.13)
payload_key Nk octets, SealDerive("payload_key", CEK, payload_info, Nk) Yes No Derived once per file from CEK; root of all epoch and segment keys; never used directly as a segment key; zeroized after use
snap_key Nh octets, SealDerive("acc_key", CEK, payload_info, Nh) Yes No Derived once per file from CEK when snap_id is not 0x0000; the snapshot authenticator's key (Section 5.8.7); zeroized after use
epoch_key(j) Nk octets, SealDerive("epoch_key", payload_key, [uint64(j)], Nk) Yes No Derived per epoch j = i >> r from payload_key; one epoch spans 2^r segments; SHOULD be cached across segments in the same epoch
segment_key(i) Nk octets, segment_key(i) = epoch_key(i >> r) Yes No The epoch key for segment i; computed per segment at encrypt/decrypt time (Section 5.8.3)
per-segment nonce (nonce_i) Nn octets No Conditional: stored only in random mode (nonce_mode 0x00); recomputed, not stored, in derived mode (nonce_mode 0x01) Random mode: sampled per segment (and resampled on rewrite). Derived mode: nonce_i derived from nonce_base, segment index, and finality bit (Section 5.8.4)
nonce_base Nn octets, SealDerive("nonce_base", CEK, payload_info, Nn) No No Present only for derived nonce mode (nonce_mode 0x01); per-segment nonces are derived from it and the segment index
snapshot value Na octets (opaque), produced by the snapshot authenticator (Section 5.8.7) No Yes (after the commitment, unless snap_id is 0x0000) Produced and updated by the snapshot authenticator from snap_key and each segment's per-segment input (Section 5.8.7); verified via SnapVerify before release; absent when snap_id is 0x0000
ciphertext digest Nh octets per segment, LH(ct_i) (Section 4.7.5 of [I-D.sullivan-cfrg-raae]) No Yes, inside the segment's metadata entry, under snap_id 0x0002 and 0x0003 only Computed per segment from its ciphertext; with the segment's tag, which every entry stores, it forms that segment's leaf, the authenticator's per-segment input at those snap_ids (Section 5.8.7)
epoch heads E * Nh octets, where E is the epoch count (Section 6.4.2) No Yes, in the aligned header, under snap_id 0x0003 only Produced by the epoch digest tree, one head per epoch (Section 4.7.6 of [I-D.sullivan-cfrg-raae])

5.8.3. Epoch Key Derivation

Per-segment encryption uses an epoch key derived from the payload key under the selected SEAL profile, following Section 4.5.2 (Epoch Key Derivation) of [I-D.sullivan-cfrg-raae]: each segment uses the epoch key for its epoch, and the payload key is never used directly as a segment key. The epoch length r is the one the selected SEAL named instantiation resolves to (Section 4.12 of [I-D.sullivan-cfrg-raae]), including that document's treatment of the 256-bit-nonce suites.

Since the epoch length is part of payload_info (Section 5.8.2), the commitment and all payload schedule outputs change when the epoch configuration changes. Implementations SHOULD cache epoch keys across consecutive segments in the same epoch. [FLOE] provides the security definitions and proofs.

5.8.4. Per-Segment Nonces

Per-segment AEAD encryption follows the SEAL encryption procedure (Section 4.8 of [I-D.sullivan-cfrg-raae], Segment Algorithms), using segment_key(i) (Section 5.8.3) and the segment AAD (Section 5.8.8). The nonce constructions are SEAL's (Section 4.5.3 of [I-D.sullivan-cfrg-raae], Nonce Generation). Tag size is 16 octets per [RFC5116]; implementations receiving a single AEAD output MUST split at offset len(output) - 16. SAFE resolves the nonce mode from the Profile (Section 5.8.1): random (0x00) or derived (0x01). The resolved mode governs what SAFE stores per segment.

In random mode (nonce_mode 0x00), Encryptors MUST use one of the nonce constructions in Section 5.8.5; each segment's nonce and tag are stored:

segment_metadata_i = nonce_i || tag_i      (Nn + 16 octets)

In the linear layout, each encrypted segment is stored as nonce_i || ciphertext_i || tag_i. In the aligned layout, segment metadata is stored apart from the ciphertext (Section 6.4), enabling single-seek segment access.

In derived mode (nonce_mode 0x01), each segment's nonce is the SEAL derived nonce (Section 4.5.3.2 of [I-D.sullivan-cfrg-raae], Derived Nonce Mode), recomputed from the key schedule rather than stored. What remains depends on the resolved snap_id:

segment_metadata_i = tag_i                    (16 octets)
                     LH(ct_i) || tag_i        (Nh + 16 octets,
                                               snap_id 0x0002, 0x0003)

Under snap_id 0x0002 and 0x0003 the entry is the segment's leaf, which the authenticator consumes (Section 4.11.2 of [I-D.sullivan-cfrg-raae]).

See Section 8.10 and Section 8.10.2 for security rationale. This design enables re-encryption of the payload without re-wrapping the CEK for each recipient, and supports selective editing of individual segments.

5.8.5. Nonce Constructions

In random mode (nonce_mode 0x00), SAFE uses the SEAL nonce constructions defined in Section 4.5.3 of [I-D.sullivan-cfrg-raae], Nonce Generation, sourcing the fresh randomness from SafeRandom(Nn, "SAFE-NONCE"). These produce Nn-octet nonces. Nonce derivation is one-pass and supports parallel segment encryption. Decryptors read the stored nonce from each segment and do not need to know which construction was used.

5.8.5.1. Plaintext-Bound Construction

Encryptors SHOULD incorporate plaintext into per-segment nonce derivation for SIV-like misuse resistance when RNG state duplication is a concern (e.g., VM snapshots, process forks without reseed). This construction is specified in Appendix A of [I-D.sullivan-cfrg-raae]; SAFE sources the fresh randomness from SafeRandom(Nn, "SAFE-NONCE"). Under RNG state duplication, it produces different nonces when plaintext differs, limiting exposure to leaking equality of identical segments at the same index.

5.8.6. Segment Rewrite Rules

Segment rewrite follows the SEAL rewrite procedure (Section 4.8.3 of [I-D.sullivan-cfrg-raae], RewriteSegment), updating the snapshot authenticator as in Section 5.8.7. The rewrite nonce follows the resolved nonce mode (Section 5.8.1), not the AEAD. In random mode (nonce_mode 0x00) the Encryptor MUST sample a fresh nonce_i = SafeRandom(Nn, "SAFE-NONCE") and update the stored nonce alongside the new tag. In derived mode (nonce_mode 0x01) the nonce for that (i, is_final) is fixed by the segment index and is reused on rewrite, which is why the derived-nonce mutable instantiation requires an MRAE AEAD (Section 5.8.1): MRAE tolerates that reuse, leaking equality rather than content.

A derived-mode rewrite is therefore deterministic: identical plaintext at the same index produces identical ciphertext. See Section 8.10.2.

5.8.7. Snapshot Authenticator

When the Profile's snap_id is not 0x0000, the object carries the SEAL snapshot authenticator that snap_id names (Section 4.7 of [I-D.sullivan-cfrg-raae]). At 0x0000 there is none, and the object provides per-segment authenticity but not whole-object integrity. The snapshot is independent of the write discipline: an immutable Profile may carry one (Section 5.8.1).

The snapshot value is an opaque field of Na octets, present only when snap_id is not 0x0000. SAFE stores it in the DATA block and supplies the authenticator with its key (from the payload schedule) and each segment's per-segment input, which the authenticator defines: the segment's AEAD tag at snap_id 0x0001, and its leaf, LH(ct_i) || tag_i, at 0x0002 and 0x0003 (Section 4.7.1 of [I-D.sullivan-cfrg-raae]). Its size, construction, update, and the integrity it provides are the authenticator's, specified in Section 4.7 of [I-D.sullivan-cfrg-raae].

Before releasing any plaintext, a Decryptor MUST run the authenticator's SnapVerify over the present segment set and the stored snapshot value, using a constant-time comparison, and MUST treat a verification failure as an authentication failure for the object.

How a Decryptor covers a single segment depends on the authenticator. Under snap_id 0x0001 the snapshot value is computed over every segment's input, so it is verifiable only over the whole object: a Decryptor that processes segments incrementally MUST complete SnapVerify once all segments have been read, and until it succeeds MUST NOT release decrypted plaintext to the caller unless it documents the weaker guarantee, that early-released plaintext carries per-segment authenticity but not snapshot integrity (Section 6.6 of [I-D.sullivan-cfrg-raae], Snapshot Integrity Limitations).

Under snap_id 0x0002 and 0x0003 a Decryptor MAY instead cover one segment on its own, which is what the write-once aligned Profiles are for (Section 5.8.1). It authenticates the snapshot from the header, without reading any ciphertext, and then follows that authenticator's own per-segment procedure, which differs between the two (Sections 4.7.5 and 4.7.6 of [I-D.sullivan-cfrg-raae]). A Decryptor MUST authenticate the snapshot before it relies on any stored leaf: a leaf that has not been authenticated is attacker-supplied, and comparing against it establishes nothing. A segment covered this way carries snapshot integrity without a read of the whole object.

5.8.8. Segment AAD

The segment AAD is the SEAL segment_aad construction (Section 4.4.2 of [I-D.sullivan-cfrg-raae], Segment AAD) with aad_label = "SEAL-DATA". Whether the segment index and the finality bit are bound in the AAD or in the nonce follows the resolved nonce mode, which that section defines. SAFE supplies an empty per-segment associated data A_i.

The is_final flag provides truncation detection: if a Decryptor decrypts segment i with is_final=0 and no subsequent segment exists, truncation has occurred. Decryptors MUST abort when truncation is detected (is_final=0 with no successor segment). For random-access reads, Decryptors SHOULD verify that the segment count N in the metadata matches the actual number of segments. The flag also prevents extension attacks: appending segments after a segment marked is_final=1 will fail AEAD verification.

For streaming writes where the total segment count is unknown, Encryptors buffer the last segment until more data arrives or the stream ends. All emitted segments use is_final=0; only when the stream closes does the Encryptor encrypt the final segment with is_final=1.

Encryptors MUST ensure segment indices remain below 2^64 in random mode (nonce_mode 0x00) and below 2^63 in derived mode (nonce_mode 0x01), where the segment index and finality bit are packed into the low 8 octets of the derived nonce (Section 4.5.3.2 of [I-D.sullivan-cfrg-raae]). Encryptors SHOULD limit i to at most 2^48 to avoid Base64 strings exceeding typical filesystem or object store limits; this is a practical recommendation, not a protocol limit. Decryptors MUST reject segment indices i where i >= 2^64 in random mode, or i >= 2^63 in derived mode.

6. File Layout

A SAFE encoding MUST consist of an optional config header (Section 6.1), followed by one or more LOCK blocks (Section 6.2), followed by exactly one DATA block (Section 6.4). Blocks MUST appear in this order; Decryptors MUST reject encodings that violate it. There is no version marker in the fences, and binary encoding carries the DATA payload without them (Section 6.3.2). Multiple LOCK blocks provide multi-recipient encryption; the DATA block is shared.

6.1. SAFE CONFIG

The config header may be omitted when all defaults apply. When present, it lists only non-default parameters. A Decryptor parses the config to determine the KDF before computing key identifiers for LOCK selection (Section 6.2.3); when the config is absent, the Decryptor uses the default KDF (hkdf-sha-256).

The header is:

-----BEGIN SAFE CONFIG-----
Profile: SEAL-simple | SEAL-attachment | SEAL-attachment-small | SEAL-editable
       | SEAL-memory | SEAL-compact
AEAD: aes-256-gcm | chacha20-poly1305 | ...
KDF: hkdf-sha-256 | turboshake256
Lock-Encoding: armored | readable
Data-Encoding: armored | binary
-----END SAFE CONFIG-----

All CONFIG fields are optional. Omitted fields use default values (Section 5.1): Profile defaults to SEAL-editable, AEAD to aes-256-gcm, KDF to hkdf-sha-256, Lock-Encoding to armored, and Data-Encoding to binary.

Profile selects one of the SEAL named instantiations (Section 4.12 of [I-D.sullivan-cfrg-raae]) by that instantiation's own name: SEAL-simple, SEAL-attachment, SEAL-attachment-small, SEAL-editable, SEAL-memory, or SEAL-compact. Each instantiation fixes the SEAL profile, the segment size, the nonce mode, the snapshot authenticator (snap_id), the epoch length, and the on-disk layout; their resolved parameters and SAFE bindings are given in Section 5.8.1. AEAD and KDF choose the cipher suite the instantiation is applied to, written SEAL-simple(aead_id, kdf_id) and so on.

CONFIG carries two kinds of options:

Key-layer options:

Profile, AEAD, and KDF determine the five elements of encryption_parameters (Section 5.5) and are folded into the KEK (Section 5.7.1), so changing any of them changes the keys. A Decryptor confirms them through the commitment (Section 5.8.2).

Representation options:

Lock-Encoding and Data-Encoding are presentational and bind nothing. Lock-Encoding specifies the LOCK block representation (Section 6.2). Data-Encoding specifies the payload format (Section 6.3): "armored" uses Base64 within DATA fence markers, and "binary" uses raw binary after the last LOCK, in the layout the Profile fixes.

The Section 10.5 registry records each option's scope.

The epoch length is pinned by the selected SEAL named instantiation (Section 5.8.1) rather than chosen in CONFIG. The 256-bit-nonce suites are the one case the instantiation does not settle on its own: Section 4.12 of [I-D.sullivan-cfrg-raae] gives them a flat key, whose 256-bit nonce makes per-key collision bounds operationally irrelevant (Section 8.14).

Implementations MUST support Lock-Encoding: armored and Data-Encoding: binary. Support for Lock-Encoding: readable and Data-Encoding: armored is OPTIONAL.

A conforming SAFE file MAY omit the SAFE CONFIG block entirely; parsers MUST treat this identically to a CONFIG block with all defaults. When CONFIG is present, it MAY contain any subset of fields. Implementations MUST construct encryption_parameters using defaults for any omitted fields, including the Profile default that fixes the segment size and snap_id.

Field names within the SAFE CONFIG are case-sensitive. Encryptors MUST NOT include duplicate field names; Decryptors MUST reject SAFE CONFIG blocks containing duplicate fields. Decryptors MUST reject SAFE CONFIG blocks containing field names not listed in the SAFE Config Options registry (Section 10.5), and MUST reject a Profile value that is not SEAL-simple, SEAL-attachment, SEAL-attachment-small, SEAL-editable, SEAL-memory, or SEAL-compact. Decryptors MUST also reject a CONFIG that selects the SEAL-compact Profile with a non-MRAE AEAD (Section 5.2.1); that instantiation requires misuse resistance (Section 5.8.1).

The order of fields within a CONFIG block is not significant. Encryptors MAY emit fields in any order; Decryptors MUST accept fields in any order.

All header lines MUST contain only ASCII characters (octets 0x20-0x7E and LF). Encryptors MUST NOT include non-ASCII characters in field names or values. Decryptors MUST reject SAFE CONFIG blocks containing non-ASCII octets or malformed UTF-8 sequences.

Implementations SHOULD bound SAFE CONFIG size; Decryptors MAY reject SAFE CONFIG headers exceeding 64 KiB.

Field values MAY wrap across multiple lines using the same rules as LOCK blocks (Section 6.2.1.2): continuation lines MUST be indented with at least two spaces, and Decryptors MUST concatenate continuation lines (stripping leading whitespace) before processing.

6.2. SAFE LOCK

A LOCK block defines one independently satisfiable access path to the CEK and carries the artifacts needed to recover it. Each LOCK contains one or more steps and exactly one Encrypted-CEK. An access path is typically a single recipient, but the abstraction is broader: a LOCK can encode a role, a group, or a recovery path, and one recipient MAY hold the credentials for several LOCKs.

The steps within a LOCK are conjunctive (AND): every step MUST be satisfied to derive that LOCK's KEK, so a LOCK that requires both a passphrase and a private key admits no single-factor unlock. Multiple LOCKs are disjunctive (OR): any one LOCK whose steps are all satisfied recovers the CEK, which is how several recipients decrypt a shared payload without duplicating ciphertext.

Steps are evaluated in the order they appear. Step-specific inputs are carried as parameters (e.g., salt= for pass, kemct= for hpke). See Section 5.6.2 and Section 5.6.3 for step-specific requirements.

The Encrypted-CEK is the concatenation of lock_nonce and the AEAD ciphertext of the CEK under the derived KEK with empty associated data (Section 5.7.2). The lock_nonce length is the AEAD's nonce size (Nn) as specified in Section 10.1. Encryptors MUST generate a fresh lock_nonce per LOCK using SafeRandom(Nn, "SAFE-LOCK-NONCE") (Section 5.4.2). Decryptors MUST reject Encrypted-CEK values that do not decode to exactly Nn + 32 + 16 octets (lock_nonce, CEK ciphertext, AEAD tag).

Decryptors MUST skip LOCK blocks containing unknown KEM identifiers or unknown step types, and attempt other LOCKs (if available). Decryptors MUST reject step descriptors containing parameter names not defined for the step type in its registration.

Implementations SHOULD bound the number of LOCK blocks; Decryptors MAY reject files containing more than 1024 LOCK blocks to prevent resource exhaustion.

Two Lock-Encoding values are defined: readable (text) and armored (binary). Both produce the same step_bindings for the KEK schedule (Section 5.6).

6.2.1. Readable Format

The readable format uses text step descriptors and colon-delimited fields:

-----BEGIN SAFE LOCK-----
Step: pass(kdf=argon2id, salt=<Base64>)
Step: hpke(kem=x25519, id=<Base64>, kemct=<Base64>)
Encrypted-CEK: <Base64>
-----END SAFE LOCK-----

Field names are case-sensitive. Encryptors MUST NOT include fields other than Step and Encrypted-CEK. Encryptors MUST include at least one Step line and exactly one Encrypted-CEK line. Decryptors MUST reject LOCK blocks containing multiple Encrypted-CEK lines or unknown field names. Optional whitespace (OWS) after colons and commas is permitted for readability.

6.2.1.1. Step Syntax

Each Step line declares a single cryptographic step. Multiple steps form an ordered sequence with AND semantics: all steps MUST be satisfied to derive the KEK. The syntax follows this ABNF, which applies after Decryptors perform line unfolding (concatenating continuation lines and stripping leading whitespace per Section 6.2.1.2):

step-line       = "Step:" OWS step-descriptor LF
step-descriptor = step-name "(" step-params ")"
step-name       = 1*( ALPHA / DIGIT / "-" )
step-params     = param *( "," OWS param )
param           = param-name "=" param-value
param-name      = 1*( ALPHA / DIGIT / "-" )
param-value     = 1*PCHAR
PCHAR           = %x21-28 / %x2A-2B / %x2D-7E
                    ; VCHAR except SP, ")", ","
OWS             = *( SP / HTAB )

The step_binding used in the KEK schedule (Section 5.7.1) is derived per Section 5.6: extract the binding fields and encode them with Encode().

Each Step line contains exactly one step descriptor. LOCK blocks with multiple steps use multiple Step lines. Step-specific inputs are carried as step descriptor parameters (e.g., salt= for pass, kemct= for hpke).

The param-value production forbids spaces (SP, 0x20) and tabs (HTAB, 0x09). Percent-encoding is not supported; all parameter values MUST be literal UTF-8 printable characters excluding whitespace.

Encryptors MUST emit parameters in the order specified by the step definition. Decryptors MUST reject step descriptors whose parameters are not in the specified order. Decryptors MUST reject step descriptors containing duplicate parameter names within a single step. Decryptors MUST reject a step descriptor that omits a required parameter (for example, a pass step without salt or an hpke step without kemct). A parameter is required when the step's ABNF grammar, given as the Parameters Grammar of its registration (Section 10.4), does not enclose it in an optional group.

See Section 5.7 for how step secrets are combined to derive the KEK. See Section 5.6.3 for the HPKE step format and Section 5.6.2 for the passphrase step format.

6.2.1.2. Line Wrapping

Field values MAY wrap across multiple lines. Continuation lines MUST be indented with at least two spaces. Decryptors MUST unfold wrapped values by concatenating continuation lines and stripping leading whitespace.

  • Step descriptors MAY wrap at parameter boundaries (after commas). Encryptors SHOULD insert a space after the comma at each wrap point. Continuation lines use 4-space indent.

  • Encrypted-CEK values use 2-space indent for continuation lines.

Encryptors SHOULD wrap lines at 64 characters. Decryptors MUST accept any line length.

A field value extends from immediately after the colon (and any following whitespace) until one of:

  1. A line starting with "Step:" (unindented)

  2. A line starting with "Encrypted-CEK:" (unindented)

  3. A fence line "-----END SAFE LOCK-----"

Trailing whitespace on individual lines SHOULD be avoided; Decryptors MUST strip trailing spaces and tabs from each line before concatenation.

Example with wrapped HPKE step descriptor:

Step: hpke(kem=ml-kem-768, hint=4217,
    kemct=bWxrZW03NjhrZW1jaXBoZXJ0ZXh0
    ZXh0cmVtZWx5bG9uZ2JhY2U2NGVuY29kZW
    RkYXRhYXBwcm94aW1hdGVseTEwODhvY3Rl
    dHNmb3JwcXNlY3VyaXR5)

Decryptors parse the text, extract field values, and produce the step Encode form for binding.

6.2.2. Armored Format

When Lock-Encoding is armored, the SAFE LOCK block contains a single Base64 value: the Base64 encoding of the LOCK serialized in its readable form (Section 6.2.1), namely the Step and Encrypted-CEK field lines without the BEGIN/END fence lines, with the final field extending to the end of the decoded value. The armored and readable encodings are the same LOCK grammar. Armored only Base64-wraps the readable serialization so the LOCK is ASCII-armored like the rest of the object.

To decode, a Decryptor concatenates the Base64 continuation lines (stripping leading whitespace), Base64-decodes them, and parses the result as a readable LOCK (Section 6.2.1). Every readable rule applies unchanged, including the step descriptor grammar, the required parameter order, the Encrypted-CEK field, and reconstruction of each step_binding from the descriptor and the Decryptor's own credentials (Section 5.6). The armored bytes are never an input to any derivation, so the LOCK key derivation (Section 5.7.1) is unchanged and the readable and armored encodings are lossless transcodings of one LOCK.

Encryptors MUST use Base64 per the rules in Section 4. The Base64 value MAY wrap across multiple lines; continuation lines MUST be indented with at least two spaces. Decryptors MUST concatenate the continuation lines (stripping leading whitespace) before decoding.

6.2.3. LOCK Selection

A decryptor parses the config header (Section 6.1) to determine the KDF, then computes key identifiers to determine candidate LOCK blocks. This does not require touching the SAFE data block.

Resolving a step is uniform across step types: given a step_descriptor and the decryptor's local credentials, a step yields zero or more ordered (step_binding, step_secret) results. It yields none when no credential matches (the LOCK is not a candidate), one when the match is unambiguous, or several when a hinted or anonymous descriptor matches more than one local credential. The candidate selection and trial decryption below produce these results for the built-in steps; the ordered pairs feed, in step order, the single LOCK key derivation (Section 5.7.1). The HPKE Identified, Hinted, and Anonymous modes are cases of this resolution, not separate mechanisms.

6.2.3.1. Candidate Selection

For each LOCK block, determine candidacy as follows:

  1. Parse the Step descriptors. If any descriptor references an unsupported step type, the LOCK is not a candidate.

  2. For each hpke(...) step, determine candidate keys based on mode:

Identified (id present): : Compute the key identifier for locally available public keys using SafeDerive("SAFE-SPKI-v1", spki_der, "", 32) with the configured KDF (default: hkdf-sha-256). Keys whose identifier matches the id parameter are candidates.

Hinted (hint present): : Look up keys in local storage associated with this hint value. Keys with matching hint are candidates.

Anonymous (neither id nor hint): : All local keys matching the kem type are candidates.

If no local recipient keys are candidates, the LOCK is not a candidate.

For auth-mode hpke(...) steps (sid or shint present), also determine candidate sender keys:

Identified (sid with Base64 value): : Keys whose identifier matches sid are candidates.

Hinted (shint present): : Keys with matching shint are candidates.

Anonymous (sid=anon): : All locally known sender keys matching the kem type are candidates.

If no sender keys are candidates for an auth-mode step, the LOCK is not a candidate.

  1. For pass(...) steps: the LOCK is a candidate if the implementation supports passphrase entry.

  2. For other registered steps: the LOCK is a candidate if the implementation supports them and local policy permits.

6.2.3.2. Attempt Order

Among remaining candidates, Decryptors SHOULD attempt LOCKs in order of confidence:

  1. LOCKs where all hpke steps are identified or hinted; the decryptor has confirmed it holds matching keys.

  2. LOCKs with anonymous hpke steps; requires trial decryption across all keys of the matching KEM type.

  3. LOCKs with pass steps; may require user interaction, so defer until key-only LOCKs are exhausted.

Encryptors MAY include multiple pass(...)-only LOCK blocks if they use different KDF variants (e.g., one pass(kdf=argon2id, ...) and one pass(kdf=pbkdf2, ...) for the same passphrase). This enables interoperability between implementations with different passphrase KDF support. Encryptors MUST NOT include duplicate pass(...)-only LOCKs with the same KDF variant. Decryptors MUST stop at the first successful CEK recovery. Decryptors MAY attempt multiple candidates in parallel.

6.2.3.3. Trial Decryption

For hinted or anonymous step sequences, Decryptors iterate through candidate key combinations. For composable step sequences (multiple hpke steps with AND semantics), trial decryption MUST consider the combinatorial product of candidates for each step. For auth-mode steps, the product includes sender key candidates in addition to recipient key candidates. For each combination:

  1. Establish an HPKE context for each hpke(...) step via SetupBaseR (or SetupAuthR with the candidate sender key for auth-mode steps) and call Export per Section 5.6.3.4

  2. Derive the KEK from the step secrets per Section 5.7.1

  3. Attempt to open Encrypted-CEK with the derived KEK

A candidate succeeds when AEAD tag verification passes on Encrypted-CEK. If the KEK is wrong, the tag will not verify.

6.2.3.4. Attempt Bound

Decryptors SHOULD limit the number of LOCKs they attempt to decrypt per credential to a reasonable upper bound. A limit of 1024 LOCKs per credential is RECOMMENDED. Files containing more LOCKs than this bound MAY be partially processed; Decryptors MUST report a clear error when the bound is exceeded rather than silently failing.

This bound complements the per-LOCK MaxTrialAttempts limit in Section 8.7.3, which bounds combinatorial key search within a single LOCK. Together they cap total trial-decryption work at 1024 LOCKs x 1024 attempts per LOCK per credential.

6.3. Data Encoding

The Data-Encoding CONFIG field specifies how the payload is represented. The Profile fixes the payload layout (Section 5.8.1), either linear (Section 6.4.1) or aligned (Section 6.4.2). Data-Encoding chooses the representation of that layout. Two encoding values are defined:

Table 14
Value Representation
armored Base64 within fence markers (linear Profiles)
binary Raw binary in the Profile's layout

The default is "binary" when Data-Encoding is omitted. Binary encoding is defined over every layout, so the default is valid for every Profile. Armored encoding is defined only over the linear layout, so it applies only to the Profiles that fix one (Section 5.8.1). A Decryptor MUST reject an object whose Data-Encoding is armored when its Profile fixes an aligned layout.

6.3.1. Armored Encoding

Armored encoding wraps the linear layout (Section 6.4.1) in Base64 with fence markers:

-----BEGIN SAFE DATA-----
<Base64: linear_payload>
-----END SAFE DATA-----

The Base64 string MAY be wrapped across multiple lines for readability. When wrapped, each line break MUST be a single LF character. For length calculations and random-access arithmetic, Decryptors MUST first remove all line breaks (LF and CRLF) and CR octets (0x0D), then strip trailing whitespace from the result. The normalized string length determines padding and segment offset computations. Decryptors MUST ignore these characters during Base64 decoding and concatenate all lines before decoding.

Encryptors SHOULD wrap Base64 lines at 64 characters. Decryptors MUST accept any line length.

Implementations MAY enforce an upper bound on payload size to prevent over-allocation; Decryptors MAY reject payloads exceeding 64 TiB of ciphertext.

Armored data arithmetic (computing segment count, byte-to-Base64 offsets, and per-segment decryption) is detailed in Appendix D.

6.3.2. Binary Encoding

Binary encoding omits fence markers. Binary data begins at the octet immediately following the LF (0x0A) that terminates the last -----END SAFE ...----- line (typically the final LOCK block). Binary data ends at EOF.

Binary encoding stores the Profile's layout (Section 5.8.1) as raw octets, whether linear (Section 6.4.1) or aligned (Section 6.4.2). The aligned layout is optimized for random access to large files via memory-mapped I/O or O_DIRECT.

Implementations that do not support binary encoding MUST fail when encountering Data-Encoding: binary, consistent with the handling of unknown AEAD or KDF values.

Encryptors that need text-safe output, such as embedding in email or a version-controlled text file, SHOULD set Data-Encoding: armored on a linear-layout Profile (Section 7.4.1). Binary is the default encoding and the only encoding for the aligned-layout Profiles.

6.4. Payload Layouts

SAFE uses two payload layouts that describe how encrypted segments are structured. The Profile fixes which layout applies (Section 5.8.1).

6.4.1. Linear Layout

The linear layout is raAE's (Section 4.11.1, Linear Layout, of [I-D.sullivan-cfrg-raae]): the 32-octet salt, the Nh-octet commitment (Section 5.8.2), the Na-octet snapshot value (Section 5.8.7), and the encrypted segments in index order, each the ciphertext and its tag with the nonce stored in front only in random mode. Under the SEAL-simple Profile there is no snapshot value (Na = 0), the reduced form of Section 4.11.4 of [I-D.sullivan-cfrg-raae]. SAFE pins raAE's streaming rule: all segments except the final one carry Segment-Size octets of plaintext, and the final segment MAY be smaller, down to zero.

Zero-length plaintexts are allowed. A zero-length plaintext produces N = 1, L_final = 0. With HKDF-SHA-256 (Nh = 32) and Nn = 12, the minimum linear payload is 156 octets under SEAL-editable (32-octet salt + 32-octet commitment + 64-octet snapshot value + 28-octet encrypted segment) and 80 octets under SEAL-simple, which stores no nonce and no snapshot value (32 + 32 + 16).

Decryptors MUST reject payloads with unexpected structure: incorrect commitment length, missing or invalid snapshot value, or segment boundaries that do not align with expected sizes.

The snapshot authenticator binds the segment count, which is not stored separately (Section 5.8.7).

Streaming readers MUST NOT infer finality from segment size. A full-size segment of exactly C octets (Nn + B + 16 in random mode, nonce_mode 0x00, and B + 16 in derived mode, nonce_mode 0x01) MAY be the final segment, since the final segment is permitted to equal Segment-Size, and a truncated file whose remainder happens to align to a C boundary is indistinguishable from a legitimate file by length alone.

The authoritative finality signal is the is_final flag bound to each segment (Section 5.8.8). A reader decrypts segments sequentially, and the segment that decrypts with is_final=1 is the final segment, whatever its size.

A reader MUST abort with a truncation error if either of the following holds:

  • it reaches EOF without decrypting a segment marked is_final=1; or

  • any decrypted segment other than the last present segment carries is_final=1 (an in-band final marker followed by trailing octets indicates extension).

Having established the segment count N this way, a reader whose Profile carries a snapshot value MUST run SnapVerify over the N segments and that value, and reject the file on mismatch. Under SEAL-simple there is none, and the per-segment binding is the whole of what the object provides (Section 5.8.1).

6.4.2. Aligned Layout

The aligned layout structures the file so that every ciphertext segment begins at an offset that is an exact multiple of the Segment-Size B. This alignment enables efficient memory-mapped I/O and O_DIRECT access, since the operating system can read any segment without copying data across page boundaries.

The file begins with a prefix carrying SAFE's own machinery: the text headers (CONFIG and LOCK blocks), then the segment count N and the first ciphertext segment index D. The raAE header follows in the field order of Section 4.11.2 of [I-D.sullivan-cfrg-raae], padded with zeros to a segment boundary, followed by zero or more padding segments for append growth, then the ciphertext segments. raAE reserves the prefix for a consuming protocol's own machinery and fixes the order of the fields that follow it (Section 4.12 of [I-D.sullivan-cfrg-raae]).

Let B denote the Segment-Size in octets (16384 or 65536). Let Nn be the nonce size (12 for AES-GCM and ChaCha20, 32 for AEGIS-256 and AEGIS-256X2). N is the segment count (uint32), and D is the first ciphertext segment index (uint32).

Two lengths follow the resolved snap_id and nonce mode rather than being fixed, and Section 4.11.2 of [I-D.sullivan-cfrg-raae] defines both. meta_len is the per-segment metadata size: an entry stores the nonce in random mode and recomputes it in derived mode, and under snap_id 0x0002 or 0x0003 it additionally stores that segment's ciphertext digest, which makes the entry Nh octets longer. Na is the snapshot value length for the resolved authenticator (Section 4.7.1 of [I-D.sullivan-cfrg-raae]), and is 0 when snap_id is 0x0000. Under snap_id 0x0003 the header also carries an epoch-heads region of E * Nh octets, where E = ceil(N / 2^r) is the epoch count at the epoch length r that the selected instantiation resolves to (Section 5.8.1). A suite that takes a flat key gives E = 1 and a single-epoch fold, which is correct and simply carries no random-access benefit (Section 5.9.6 of [I-D.sullivan-cfrg-raae]).

Each row below represents one Segment-Size:

CONFIG+LOCK N D salt commitment snapshot [epoch heads: 0x0003 only] | metadata... ...metadata (cont.) | padding padding (optional append growth) ct0 ct1 ...

Each metadata entry is the raAE metadata entry of Section 4.11.2 (Aligned Layout) of [I-D.sullivan-cfrg-raae].

The binary portion immediately follows the text headers. The first two fields are SAFE's, and the rest are the raAE header in that document's order:

  • N: segment count (uint32, big-endian)

  • D: first ciphertext segment index (uint32, big-endian)

  • salt (32 octets)

  • commitment (Nh octets)

  • snapshot value: Na octets, absent when snap_id is 0x0000 (Section 5.8.7)

  • epoch heads: E * Nh octets, present only when snap_id is 0x0003

  • metadata: N entries, each meta_len octets

  • padding to segment boundary, then zero or more padding segments

  • ciphertext segments starting at offset (D × B)

The uint32 segment count limits aligned-layout files to 2^32 - 1 segments. The Profile fixes the layout (Section 5.8.1), so an object needing more segments is not representable under an aligned-layout Profile: an Encryptor selects a linear-layout Profile instead.

6.4.2.1. Writing

To write an aligned-layout file, the Encryptor computes D as follows. Let H be the total octet count of everything before the ciphertext: all CONFIG and LOCK text (including fence markers and newlines), plus 4 (N) + 4 (D) + 32 (salt) + Nh (commitment, 64 for TurboSHAKE-256) + Na (the snapshot value) + E * Nh (the epoch heads, only when snap_id is 0x0003) + N * meta_len (metadata). Then:

D = ceil(H / B)

Padding segments for append growth MAY be added by increasing D beyond the minimum value. The encryptor writes the header, pads to D * B octets, then writes ciphertext segments at offsets D * B, (D+1) * B, and so on.

The aligned layout requires a seekable output. For streaming writes where N is unknown at the start, the Encryptor estimates a maximum segment count, computes D from that estimate, reserves space for N, D, the metadata, and the epoch heads when snap_id is 0x0003, then seeks back to fill the actual values once the stream closes. E follows from N, so an estimate that bounds N bounds the heads region too.

6.4.2.2. Reading

To read an aligned-layout file:

  1. Parse CONFIG and LOCK text to determine the Profile and AEAD.

  2. Read N and D, then the 32-octet salt and the Nh-octet commitment.

  3. Re-derive the commitment and reject the object on mismatch (Section 8.13). The commitment is reachable from the salt and the text headers alone, so this precedes any allocation sized by N, which nothing has authenticated yet.

  4. When snap_id is not 0x0000, read the Na-octet snapshot value.

  5. When snap_id is 0x0003, read the epoch-heads region, E * Nh octets.

  6. Read N metadata entries, each meta_len octets. Under snap_id 0x0002 and 0x0003 an entry stores that segment's leaf.

  7. Verify the snapshot before relying on anything it covers (Section 5.8.7), using a constant-time comparison and rejecting the object on mismatch. Under snap_id 0x0001 this is SnapVerify over the whole segment set. Under 0x0002 and 0x0003 the authenticator verifies from the header alone, so a Decryptor reading one segment follows the per-segment procedure of Section 4.7.5 or 4.7.6 of [I-D.sullivan-cfrg-raae], which consumes the regions read in steps 4 through 6.

  8. Ciphertext segment i is at offset (D + i) × B.

To read only segment i, the nonce source follows the resolved nonce mode. In derived mode (nonce_mode 0x01) compute nonce_i from nonce_base and the segment index. In random mode (nonce_mode 0x00) read the nonce from the metadata entry. Read the tag from the metadata entry in both cases. Then read B octets (or fewer for the final segment) at offset (D + i) x B. The final segment's ciphertext length is file_size - (D + N - 1) * B octets.

7. Compatibility and Migration

7.1. Handling Unknown Elements

Decryptors processing SAFE-encoded data MUST:

  • Fail if they encounter an unrecognized or unimplemented value for any CONFIG field (Profile, AEAD, KDF, Data-Encoding, Lock-Encoding). Implementations MUST NOT silently ignore CONFIG values they do not support.

  • Reject a Profile value that Section 5.8.1 does not define.

  • Skip LOCKs containing unknown field names, KEM identifiers, or step types and attempt other LOCKs (if available).

  • Fail if a CONFIG block contains field names not listed in the SAFE Config Options registry (Section 10.5).

  • Fail if the payload has unexpected structure (wrong commitment length, trailing octets, misaligned segment boundaries).

  • Skip unknown block types if the IANA SAFE Block Types registry (Section 10.6) marks them as Ignorable; otherwise fail.

7.2. Versioning

This document defines SAFE version 1, identified by fence markers ("-----BEGIN SAFE CONFIG-----", etc.). Future incompatible versions would use different fence markers or a new media type. New features SHOULD be added through IANA registries rather than format version changes.

7.3. Extension Points

SAFE provides IANA registries for AEADs (Section 10.1), KEMs (Section 10.2), step types (Section 10.4), and block types (Section 10.6).

Unknown block types are critical by default: Decryptors MUST fail if they encounter an unrecognized block. The IANA SAFE Block Types registry (Section 10.6) MAY mark specific block types as Ignorable, enabling forward-compatible optional extensions such as metadata or signatures that older implementations can safely skip.

7.4. Application Profiles

This section is informative. It describes parameter combinations for common deployment scenarios. These profiles compose the CONFIG fields defined in Section 6.1 and do not introduce new protocol elements.

7.4.1. Objects

Applications that prioritize text-safe output and maximum interoperability SHOULD use the default parameters (Section 5.1) with armored data:

-----BEGIN SAFE CONFIG-----
Data-Encoding: armored
-----END SAFE CONFIG-----

The resulting SAFE object is entirely printable ASCII and can be embedded in email, JSON, YAML, or version-controlled files. AES-256-GCM is the default AEAD (Section 5.1).

7.4.2. Streaming

Applications that process data sequentially at high throughput SHOULD consider:

-----BEGIN SAFE CONFIG-----
AEAD: aegis-256
-----END SAFE CONFIG-----

AEGIS-256 offers high throughput and a 32-octet nonce that simplifies nonce management. The default Profile SEAL-editable uses a linear layout, so binary encoding (Section 6.4.1) yields minimal framing overhead and sequential I/O without alignment padding. Encryptors using this profile SHOULD apply the hedged nonce construction (Section 5.4.2.2) or plaintext-bound nonce construction (Section 5.8.5.1) per Section 8.10.

Encryptors targeting broad interoperability SHOULD verify recipient support before selecting this profile.

7.4.3. Edit

Applications that perform random-access reads and writes on encrypted data SHOULD consider:

-----BEGIN SAFE CONFIG-----
Profile: SEAL-compact
AEAD: aes-256-gcm-siv
-----END SAFE CONFIG-----

The SEAL-compact profile is derived-nonce and aligned (Section 5.8.1). Per-segment nonces are derived rather than stored (Section 5.8.4), reducing per-segment metadata from Nn + 16 octets to 16 octets. The SEAL-compact profile fixes Segment-Size 16384, aligning each segment to a single page on 16 KiB-page systems and minimizing page faults per edit. Binary aligned encoding (Section 6.4.2) enables O(1) random access to any segment via memory-mapped I/O.

Re-encrypting a modified segment reuses the derived nonce for that segment index. Because AES-256-GCM-SIV is misuse-resistant, this degrades to deterministic encryption for unchanged segments rather than catastrophic nonce reuse (Section 8.10.2).

7.4.4. FIPS Edit

Applications that require FIPS 140-validated algorithms for random-access editing SHOULD consider:

-----BEGIN SAFE CONFIG-----
Profile: SEAL-memory
AEAD: aes-256-gcm
-----END SAFE CONFIG-----

AES-256-GCM is FIPS 140-validated; ChaCha20-Poly1305, AES-256-GCM-SIV, AEGIS-256, and AEGIS-256X2 are not currently covered by a FIPS 140 validation program. The SEAL-memory profile fixes Segment-Size 16384 and takes its instantiation's epoch length (Section 5.8.1), deriving segment keys through the epoch schedule (Section 8.14.2).

Unlike SEAL-compact (Section 7.4.3), this profile stores random per-segment nonces (Nn + 16 octets per segment vs. 16 octets in derived mode). Applications that can tolerate segment-level equality leakage on rewrite SHOULD prefer SEAL-compact for its lower per-segment overhead.

8. Security Considerations

SAFE provides:

Confidentiality:

ra-ROR (random-access real-or-random) security for the payload, the joint confidentiality and authenticity notion for segmented random-access encryption formalized in [FLOE]. An adversary with adaptive encryption and decryption access cannot distinguish ciphertext segments from random and cannot make an out-of-context segment decrypt. SafeDerive is a PRF, so payload_key is indistinguishable from random, and under a random key the SEAL payload layer provides ra-ROR per the analysis in [FLOE]. The commitment prefix meets the companion ra-CMT (random-access context commitment) notion (Section 8.13). ra-ROR gives per-segment integrity only. Whole-object integrity comes from the snapshot authenticator when snap_id is not 0x0000, as described under Authentication.

Authentication:

Each LOCK's Encrypted-CEK is authenticated via AEAD under derived_kek, which binds step bindings and step secrets through the KEK schedule. Segment AEAD binds each segment to its index (Section 8.3), preventing reordering, modification, and splicing. The snapshot authenticator (Section 5.8.7), present in every Profile except SEAL-simple (Section 5.8.1), provides file-level integrity and authenticity over every segment's per-segment input and the segment count.

Binding:

The KEK schedule binds encryption_parameters in the LOCK's single KEK derivation (Section 5.7.1). All step bindings and per-step secrets are bound in that one SafeDerive call. Payload keys inherit suite binding from their own SafeDerive calls (Section 5.8.2).

SAFE does not provide:

Encryptor authentication (Base mode):

Without a sender parameter (sid or shint), any party with recipient public keys can create SAFE-encoded data. See Section 8.2 for Auth mode authentication properties.

Forward secrecy:

CEK compromise exposes all recipients' copies. This is inherent to stored-object encryption, which has no interactive key exchange.

Unlinkability:

Key identifiers enable linking SAFE-encoded data to the same recipient. See Section 8.7 for privacy modes.

SAFE assumes secure key storage, side-channel resistant implementations, and trusted cryptographic primitives. A functioning CSPRNG is REQUIRED. See Section 8.10 for defenses against RNG weakness or state duplication.

8.1. Threat Model

SAFE defends against:

Compromised storage provider (confidentiality):

An adversary with read access to stored SAFE-encoded data cannot decrypt without valid credentials (passphrase or private key) for at least one LOCK. The adversary can observe approximate file size, recipient count, and key identifier linkability. SAFE detects segment corruption (AEAD failure), truncation (is_final mismatch), and segment-count truncation or extension (the snapshot authenticator binds the segment count), but does not detect LOCK removal, LOCK addition, or whole-file replacement under a different CEK by an adversary with write access.

SAFE does not defend against:

Compromised recipient:

If a recipient's credentials (passphrase or private key) are compromised, the adversary can decrypt the payload. All recipients share the same CEK; compromise of one recipient's KEK does not expose other recipients' KEKs, but does expose the shared CEK and payload. The weakest LOCK determines the effective security of the entire file: an attacker who can satisfy any single LOCK recovers the CEK.

Active attacker with key compromise:

If an attacker compromises a recipient's private key and can modify files, they can create valid SAFE-encoded data for that recipient (in Base mode). Auth mode (Section 5.6.3.1) mitigates this for steps where the attacker does not also hold the sender's private key; see Section 8.2.

Side-channel attacks:

SAFE assumes implementations do not intentionally leak secrets. Timing attacks on Argon2id, HPKE, or AEAD operations are out of scope for this document.

Malicious Encryptor:

Any party with a recipient's public key can create valid SAFE-encoded data for that recipient. SAFE does not constrain what an Encryptor can encrypt or for whom. Applications MUST validate decrypted content independently of the encryption envelope.

8.2. Sender Authentication Properties

When sid or shint is present in an hpke(...) step, SAFE uses HPKE Auth mode (mode_auth, [RFC9180]). Auth mode defends against forgery by parties who do not hold the sender's private key: a decryptor who successfully processes an auth-mode step is assured that the encapsulation was produced by a holder of skS. This closes the "encryptor authentication" gap identified above for Base mode, within the following limits:

Non-repudiation:

Auth mode authenticates the sender only to the holder of the recipient's private key. The recipient cannot prove to a third party that the sender created the SAFE object. Applications requiring non-repudiation MUST use external signatures.

Sender identity confidentiality:

The sid parameter (when a Base64 value) reveals the sender's key identifier to any observer. shint narrows the sender's identity. Anonymous Auth mode (sid=anon) avoids explicit sender identification, at the cost of trial decryption across all candidate sender keys.

Sender key trust:

SAFE does not define a trust model for sender public keys. Decryptors MUST independently verify that a sender's public key is authentic (e.g., via a certificate, trust on first use (TOFU), or out-of-band verification) before relying on auth-mode authentication.

8.3. Integrity and Authenticity

The KEK schedule binds encryption_parameters and all step bindings and per-step secrets in a single SafeDerive call. The payload schedule binds payload_key to payload_info independently, tying segment encryption to the negotiated AEAD, Segment-Size, and the other SEAL parameters. Payload AEAD binds each segment to its index and its finality, preventing reordering and cross-file splicing, and SAFE detects truncation and extension at segment boundaries from that finality binding. Whether it lives in the segment AAD or in the nonce follows the resolved nonce mode (Section 5.8.8).

The snapshot authenticator (Section 5.8.7) provides file-level integrity for every Profile whose snap_id is not 0x0000 (Section 5.8.1). Its detection set, construction, and security are specified there and in Section 4.7 of [I-D.sullivan-cfrg-raae]. The is_final flag bound to each segment additionally detects a file that ends without a final-marked segment (Section 5.8.8).

The snapshot authenticator provides no freshness. A complete earlier snapshot of the file, presented with its matching snapshot value, verifies as valid (Section 6.6 of [I-D.sullivan-cfrg-raae], Snapshot Integrity Limitations). Applications that require rollback protection MUST bind the current snapshot value to external trusted state.

Applications requiring third-party verifiability (e.g., signatures) MUST use external signatures.

8.4. Cross-Application Domain Separation

SAFE's payload layer derives in the selected SEAL domain (SEAL-RW-v1 or SEAL-RO-v1, Section 5.8.1), shared with any application that adopts the same SEAL profile verbatim.

Identical derivations across applications require both the same CEK and the same payload_info (Section 6.11 of [I-D.sullivan-cfrg-raae], Cross-Application Domain Separation). The fresh 32-octet random salt SAFE generates per file is the final payload_info element, so the condition requires reusing both a CEK and a salt across applications. An application pair that reuses both accepts the other's payload-layer objects as valid.

The SAFE key layer (KEK schedule, step secrets, and key identifiers) remains in the SAFE-v1 domain.

8.5. Implementation Considerations

The step sequence has AND semantics: an attacker must break every step to recover the CEK, so security is at least that of the strongest step. Because the KEK is a single combiner call over all step secrets, an attacker who learns a proper subset of them learns nothing about the KEK.

lock_nonce MUST be fresh per LOCK (Section 5.7.2). Per-segment nonces follow the resolved nonce mode (Section 5.8.4).

Implementations MUST zeroize sensitive values (CEK, KEK, PRKs, payload_key, snap_key, nonce_base) immediately after use. Long-lived processes that retain a CEK (e.g., for incremental writes) SHOULD store it in swap-protected memory (e.g., mlock).

To prevent error oracles, implementations exposing decryption to untrusted callers (e.g., network services, APIs) MUST return a single generic "decryption failed" error rather than distinguishing between wrong passphrase, wrong key, commitment mismatch, or AEAD failure. Local tools (e.g., CLI applications, test harnesses) MAY use the detailed error codes in Appendix C for diagnostics.

Implementations MUST evaluate the cryptographic operations that touch secret data in constant time, as required by Section 6.9 (Constant-Time Implementation) of [I-D.sullivan-cfrg-raae]. The SAFE-specific surfaces this covers are passphrase KDF evaluation and Base64 decoding of secret material (Encrypted-CEK, step parameters carrying key material). Commitment and snapshot-value comparisons MUST use a constant-time equality function (e.g., CRYPTO_memcmp or equivalent), and trial decryption loops (Section 8.7.3) MUST NOT leak timing information about which candidate key succeeded.

8.6. Passphrase KDF Selection

SAFE supports two passphrase KDF variants with different security properties:

pass(kdf=argon2id, ...):

Memory-hard function that resists GPU and ASIC attacks. The default parameters (64 MiB memory, 2 iterations) provide strong resistance to offline attacks. Recommended for most deployments.

pass(kdf=pbkdf2, ...):

Widely deployed function using PBKDF2-HMAC-SHA-256. Lacks memory-hardness, making it more vulnerable to GPU and ASIC attacks than Argon2id. The 600,000 iteration count provides equivalent CPU-based attack resistance but does not mitigate hardware-based attacks. Use only when policy prohibits memory-hard KDFs.

Encryptors targeting Decryptors with mixed policy constraints MAY include two pass(...) LOCK blocks: one with pass(kdf=argon2id, ...) and one with pass(kdf=pbkdf2, ...), using the same passphrase but fresh salts for each.

A captured SAFE object gives an attacker an offline guessing oracle against passphrase-only LOCK blocks: each candidate passphrase is tested by deriving the KEK and attempting to open the LOCK's Encrypted-CEK (AEAD tag verification, Section 5.7.2), with no online interaction and no rate limit. KDF stretching raises the per-guess cost but does not remove the oracle, so the passphrase's entropy, not the KDF, is the decisive defense. Applications SHOULD therefore enforce a minimum passphrase complexity policy (e.g., at least 20 characters or equivalent entropy). For high-value data, Encryptors SHOULD combine a pass(...) step with an hpke(...) step in the same LOCK, so that compromise of the passphrase alone is insufficient.

8.7. Recipient Anonymity and Trial Decryption

SAFE supports three levels of recipient identification for hpke(...) steps:

Identified mode:

The id parameter uniquely identifies the recipient's public key. Observers can link SAFE-encoded data encrypted to the same recipient.

Hinted mode:

The hint parameter is a recipient-assigned value (not cryptographically derived). It filters candidates locally while revealing nothing about the key itself. Multiple keys may share the same hint.

Anonymous mode:

No identifier is present. Decryptors MUST trial-decrypt against all local keys matching the kem type. Provides maximum privacy at the cost of increased decryptor computation.

These modes are properties of the LOCK, not of its encoding. The readable and armored forms (Section 6.2.2) carry the same step descriptors, so an id or hint is present in both encodings or in neither, and anonymous mode omits both in both encodings. A passive observer therefore learns a linkable key identifier only when the encryptor chooses identified mode, whichever Lock-Encoding is used.

8.7.1. Privacy Benefits

Omitting or replacing the key identifier with a hint prevents passive observers from mapping SAFE-encoded data to specific public keys. This is valuable when file-recipient associations are sensitive metadata.

8.7.2. Sender Anonymity

Auth-mode hpke(...) steps support the same three levels of sender identification via sid and shint:

Identified (sid present):

The sid parameter identifies the sender's public key using the same KDF as id. Observers can link SAFE objects to the same sender across files.

Hinted (shint present):

The shint parameter is a sender-assigned value that filters candidates locally. It reveals less than sid but still narrows the sender's identity.

Anonymous (sid=anon):

Decryptors trial-decrypt against all locally known sender keys matching the kem type. Provides sender privacy at the cost of increased trial decryption (see Section 8.7.3).

Encryptors SHOULD prefer sid unless sender privacy is required. The same trade-offs between identification, hinting, and anonymity apply to sender keys as to recipient keys.

8.7.3. Trial Complexity

Anonymous mode with composable step sequences (multiple hpke steps) requires combinatorial trial decryption. For a step sequence with two anonymous hpke(...) steps, where the Decryptor holds K1 keys for step 1 and K2 keys for step 2, up to K1 x K2 combinations may be attempted. Auth-mode steps add a further multiplicative factor: if an auth-mode step uses sid=anon, the Decryptor MUST try all S candidate sender keys, multiplying the search space by S.

Implementations MUST set a MaxTrialAttempts limit to bound computation and MUST reject LOCK blocks that would exceed this limit. A value of 1024 is RECOMMENDED; implementations MAY adjust based on deployment constraints.

8.8. Denial of Service Considerations

An attacker can craft SAFE-encoded data with many anonymous LOCK blocks to force Decryptors into expensive cryptographic operations. Implementations MUST:

  • Limit the number of LOCK blocks processed per object

  • Prioritize identified blocks over hinted blocks over anonymous blocks

  • Abort early when resource limits are exceeded

Implementations MUST also limit the number of steps per LOCK block. A limit of 16 steps per LOCK is RECOMMENDED; this prevents an attacker from crafting a single LOCK block that forces evaluation of an excessive number of passphrase KDF computations. Implementations SHOULD limit the total number of passphrase KDF evaluations to 8 per file; an attacker who crafts multiple LOCKs with pass(...) steps can otherwise force expensive Argon2id computations proportional to the LOCK count.

ML-KEM decapsulation is significantly more expensive than X25519; anonymous ML-KEM steps amplify the DoS potential. See Section 6.2.3 for the recommended per-credential LOCK bound.

8.9. Hint Assignment

The hint is a 4-digit decimal value (0000-9999) assigned by the recipient; it is not solely dependent on the public key. Recipients communicate their hint to Encryptors out-of-band. Multiple keys MAY share the same hint.

Encryptors MUST NOT assume the hint uniquely identifies a key. Decryptors MAY reassign hints at any time; Encryptors SHOULD refresh hint values periodically through out-of-band communication.

Encryptors SHOULD prefer identified mode unless recipient privacy is required.

8.10. Nonce Generation and CEK Reuse

Encryptors SHOULD use the hedged construction (Section 5.4.2.2) when a private key is available, the plaintext-bound nonce construction (Section 5.8.5.1) when RNG state duplication is a concern, and an MRAE AEAD (Section 5.2.1) for additional protection. The following cases describe the resulting security properties.

With private key, working RNG:

Full protection. The segment nonce base is derived from both fresh randomness and the hedge key. Nonces are unique across files and within files.

With private key, duplicated RNG state:

Deterministic encryption per Encryptor. Different Encryptors (with different private keys) produce different hedge keys and therefore different CEKs and nonce bases. Within a single file, segment indices guarantee nonce uniqueness. Across files from the same encryptor, the CEK and salt repeat, producing identical payload keys and ciphertext for identical plaintext segments at the same index. This leaks equality but not content. The plaintext-bound nonce construction (Section 5.8.5.1) further limits exposure: nonces differ when plaintext differs, even across files.

Without private key, working RNG:

Full protection. SafeRandom returns raw CSPRNG output. Nonce uniqueness depends on the RNG.

Without private key, duplicated RNG state:

No defense. CEKs and salts repeat across files, producing identical payload keys and nonce bases. Within a file, segment indices still provide distinct nonces. Across files, nonce reuse under distinct plaintext permits key recovery attacks against AES-GCM, ChaCha20-Poly1305, AEGIS-256, and AEGIS-256X2. AES-256-GCM-SIV limits the damage to deterministic encryption (leaks equality). The plaintext-bound nonce construction also limits nonce reuse to identical segments at the same index.

A functioning CSPRNG is REQUIRED when no private key is available.

RNG state duplication arises from VM snapshots, process forks without reseed, and container cloning. Because the plaintext-bound construction incorporates a collision-resistant digest of the segment plaintext into nonce derivation (Appendix A of [I-D.sullivan-cfrg-raae]), two instances that share identical key material still produce distinct nonces whenever plaintext differs, which justifies its two-pass cost.

Within a single file, segment indices are bounded by the plaintext length and Segment-Size. Encryptors MUST ensure segment indices remain below 2^64 in random mode and below 2^63 in derived mode (Section 5.8.8). Practical implementations SHOULD enforce a lower bound; for example, rejecting plaintexts exceeding 2^48 segments (approximately 16 EiB at the default Segment-Size of 65536 octets) provides a conservative margin while supporting files far larger than current storage systems.

8.10.1. File Extension

Only a mutable Profile can be extended (Section 5.8.1). Appending data to such a file requires re-encrypting the old final segment (which had is_final=1) with is_final=0, then encrypting the new segments. Encryptors MUST NOT generate a new CEK or salt. The existing LOCKs and salt are reused. Encryptors MUST run SnapVerify before extending the file. Extending a corrupted file propagates undetected damage. The procedure is:

  1. Decrypt the current final segment (index N-1) and verify is_final=1.

  2. Re-encrypt segment N-1 with is_final=0. In random mode (nonce_mode 0x00), Encryptors MUST generate a fresh nonce using SafeRandom (Section 5.4.2). In derived mode (nonce_mode 0x01), the nonce binds is_final (Section 5.8.4), so re-encrypting segment N-1 with is_final changed from 1 to 0 yields a different nonce, not a repeated one. See Section 8.10.2.

  3. Encrypt new segments N through N+K-1 with is_final=0.

  4. Encrypt segment N+K (the new final segment) with is_final=1.

  5. Update the metadata entries for the changed and new segments, and, under an aligned-layout Profile, the stored segment count N. A linear layout stores no count (Section 6.4.1).

  6. Update the snapshot authenticator for the changed segments and re-seal the snapshot value (Section 5.8.7).

The ciphertext of segment N-1 changes, so an observer can detect that it was re-encrypted, but its content is not revealed.

8.10.2. Derived Nonces

In derived mode (nonce_mode 0x01), per-segment nonces are derived deterministically from nonce_base and the segment index rather than generated randomly and stored. The conditions under which this is safe (an MRAE AEAD for the rewrite reuse the mutable profile permits, the write-once discipline for a non-MRAE AEAD) are given in Section 4.5.3.2 of [I-D.sullivan-cfrg-raae].

One uniqueness anchor is SAFE-specific. When the immutable (write-once) profile is used with a non-MRAE AEAD and a CEK is reused across objects, the per-file salt is the sole anchor of cross-object nonce uniqueness: nonce_base = SealDerive("nonce_base", CEK, payload_info, Nn), and payload_info includes the per-file salt (Section 5.8.2). A duplicated salt therefore repeats every derived nonce across the two objects, a plaintext-recovery failure. raAE mandates a fresh salt per write-once object (Section 4.5.3.2 of [I-D.sullivan-cfrg-raae]).

8.11. Selective Editing Security

Per-segment nonces and the is_final flag enable selective editing: individual segments can be re-encrypted without affecting other segments or LOCK blocks. When editing:

  • Source the re-encrypted segment's nonce per the resolved nonce mode: a fresh random nonce in random mode, or the segment's derived nonce in derived mode, whose reuse the required MRAE AEAD tolerates (Section 5.8.6, Section 8.10.2)

  • Update the is_final flag if the last segment changes

  • Update the snapshot authenticator and re-seal the snapshot value (Section 5.8.7)

  • Segments not being edited retain their original nonces and ciphertexts

The is_final flag prevents truncation and extension attacks:

  • Truncation: decrypting a segment with is_final=0 when no successor exists indicates malicious or accidental truncation

  • Extension: appending segments after a segment with is_final=1 will fail AEAD verification, because the original final segment is bound to is_final=1 (Section 5.8.8)

8.12. Key Identifier Collisions

Key identifiers are 32-octet hashes of SPKI DER encodings (Section 5.6.3.3). The id derivation (Section 5.6.3.3) requests 32 octets from either registered KDF (hkdf-sha-256 or turboshake256) by passing L=32 to SafeDerive, giving a birthday bound on collision probability of approximately N^2 / 2^257 for a deployment with N keys. This is negligible for any practical key population.

For an SPKI that exceeds the 0xFFFE-octet literal limit, the key identifier composes two 256-bit hash steps (the over-large-field digest LH of Section 4.3 (The KDF Combiner) of [I-D.sullivan-cfrg-raae] over the SPKI, then SafeDerive over the resulting digest entry). The collision bound is unchanged because a collision still requires a 256-bit hash collision.

Implementers MUST NOT rely solely on key identifier matching for authorization; successful HPKE decapsulation and AEAD verification of Encrypted-CEK are required.

8.13. Key Commitment

The commitment is the SEAL commitment (Section 4.6 of [I-D.sullivan-cfrg-raae], Commitment), derived as Section 5.8.2 gives.

SAFE supports multiple LOCK blocks that can be added or removed independently without re-encrypting the payload, because each LOCK wraps the same CEK. However, removing a LOCK does not revoke access: any party who previously decrypted the CEK retains the ability to decrypt the payload. Applications requiring revocation MUST generate a new CEK and re-encrypt.

SAFE's external Nh-octet commitment prefix (Section 5.8.2) is the sole check that binds the Decryptor's resolved protocol_id and nonce mode to the file. A Decryptor MUST verify it before any segment key derivation (Section 5.8.2). Why this prefix is needed, why no registered AEAD supplies equivalent commitment from its own mechanism, and the random-access context-commitment (ra-CMT) target it meets are analysed in Section 5.7 (Commitment Security) of [I-D.sullivan-cfrg-raae].

The check binds the ciphertext to the CEK, the negotiated algorithm parameters, and the per-file salt, preventing cross-algorithm commitment collisions. The derivation is given in Section 5.8.2, and the collision bound (which rests on the collision resistance of the commit derivation, not on PRF security) in Section 5.7 (Commitment Security) of [I-D.sullivan-cfrg-raae].

The commitment prefix also covers the KEK layer. None of the registered AEADs used for CEK wrapping (Section 5.7.2) is key-committing, and nothing forces distinct LOCK blocks to wrap the same CEK, so a malicious encryptor can construct LOCKs that unwrap to different CEK values for different recipients, the failure class [AGE-COMMIT] demonstrates against age. The commitment check closes this gap. Each Decryptor re-derives the commitment from its recovered CEK and rejects on mismatch before any segment key derivation, and producing two CEK values that both match the stored prefix requires a collision in the commit derivation. Recipients that accept the same object therefore hold the same CEK and decrypt identical plaintext.

8.14. AEAD Usage Bounds

Per-AEAD confidentiality and integrity bounds are analysed in Section 5.9 of [I-D.sullivan-cfrg-raae], Capacity and Usage Limits. This section adds the SAFE-specific considerations that arise from multi-recipient sharing of a single CEK, lifetime rewrite accounting, and the relationship to SAFE's uniform key commitment.

See Section 8.10 for defenses against RNG weakness and state duplication, including the hedged nonce construction (Section 5.4.2.2, [RFC8937]). Key commitment (Section 8.13) and segment-level integrity (Section 5.8.8) address threats orthogonal to nonce discipline and are analysed in Section 5.7 (Commitment Security) and Section 5.6 (Segment Confidentiality and Integrity) of [I-D.sullivan-cfrg-raae].

8.14.1. Lifetime and Multi-Recipient Accounting

Let q denote the total number of segment encryptions under one payload key, counting every initial segment write and every segment rewrite. Because all recipients of a SAFE object share one CEK and therefore one payload key, the per-key bound from [I-D.sullivan-cfrg-raae] applies regardless of recipient count; adding recipients does not increase q.

The same q can be reached by a large initial write or by repeated rewrites of a smaller file. A 1 GiB file contains 2^14 segments at the default segment size; rewriting the entire file 1024 times produces approximately 2^24 total segment encryptions, the same q as encrypting 1 TiB once. The relevant quantity is lifetime encrypted segments per payload key, not current file size.

In a multi-user setting where an attacker targets any one of U independently keyed SAFE files, the effective nonce-collision probability for 96-bit-nonce AEADs is approximately:

U * q^2 / 2^(8*Nn + 1)

For U = 2^20 files each at q = 2^32, this is approximately 2^(-13), which is not negligible. The epoch key schedule (Section 8.14.2) mitigates this by spreading segment encryptions across multiple epoch keys instead of one payload key: the 96-bit-nonce suites use epoch length 16 for the mutable instantiations (SEAL-editable, SEAL-memory, SEAL-compact), so one epoch key covers 2^16 segments rather than the whole file. This document does not restate a mitigated multi-user bound for epoch length 16: the per-key q at this granularity depends on how rewrites distribute across the 2^16 segments sharing an epoch key, which needs its own derivation and is not a simple substitution into the single-segment (epoch length 0) case. The 256-bit nonce AEADs (AEGIS-256, AEGIS-256X2) take a flat key regardless of Profile (Section 5.8.1), and their nonce size renders multi-user bounds operationally irrelevant in any case (approximately 2^(-173) at the same parameters).

Each epoch key, not the CEK, carries the AEAD encryption budget (Section 5.9.1 of [I-D.sullivan-cfrg-raae]). Applications MUST track segment encryptions per key and freeze the object before a budget is exceeded (Section 5.9.5 of [I-D.sullivan-cfrg-raae]); an implementation that reaches a budget re-encrypts the object under a fresh CEK rather than continuing under the same key. At the epoch length SAFE pins for the mutable instantiations (Section 5.8.1), the per-segment rewrite share is roughly 2^(32 - epoch_length) for the 96-bit-nonce suites, so repeated rewrites of one segment are the case that reaches it first (Section 6.8 of [I-D.sullivan-cfrg-raae]).

8.14.2. Epoch Key Rotation

The epoch key schedule (Section 5.8.3) limits AEAD invocations per epoch key, so the birthday bound applies per epoch key rather than file-wide. The instantiation's pin (Section 5.8.1) trades some per-key rotation for fewer epoch-key derivations across rewrites, and a caller cannot coarsen it further to trade rewrite budget for fewer key derivations. Epoch keys are derived, not stored, so no LOCK re-wrapping is needed. The per-epoch rewrite budget and the hot-segment concentration case are in Section 5.9.5 (Rewrite Capacity) and Section 5.9.6 (Choosing epoch_length) of [I-D.sullivan-cfrg-raae].

The choice of layout (linear or aligned, Section 6.4) does not change the cryptographic analysis; it affects only framing and on-disk storage.

8.15. Algorithm Agility and Post-Quantum Support

SAFE accommodates post-quantum KEMs without format changes. ML-KEM-768 (HPKE KEM ID 0x0041) is registered and MAY be used as kem=ml-kem-768.

Hybrid post-quantum constructions require no protocol extensions. An encryptor lists both a classical and a post-quantum hpke(...) step in the same step sequence:

Step: hpke(kem=x25519, kemct=<Base64>, id=<classical-id>)
Step: hpke(kem=ml-kem-768, kemct=<Base64>, id=<pq-id>)

The KEK schedule (Section 5.7.1) binds both steps' secrets in one derivation, so the derived KEK depends on both the X25519 and ML-KEM-768 shared secrets, and an attacker must break both KEMs to recover it. Because every step secret enters that single derivation, the LOCK's security is conjunctive in its steps. Hybrid post-quantum protection follows naturally from combining classical and post-quantum steps.

The decryptor evaluates both decapsulations during CEK recovery. Each KEM ciphertext is carried in the kemct parameter of its corresponding hpke(...) step descriptor. See Appendix A for a complete example.

Implementations planning PQ migration SHOULD ensure kemct parsing does not impose unnecessary length limits (ML-KEM-768 ciphertexts are 1088 octets).

Auth mode (Section 5.6.3.1) relies on DHKEM AuthEncap/AuthDecap, which requires a CDH-hard group. No post-quantum KEM currently supports Auth mode; ML-KEM-768 MUST NOT be used with Auth mode (Section 5.6.3.2). Applications requiring post-quantum sender authentication MUST use external signatures.

8.16. Security Level and Design Notes

SAFE derives all symmetric keys at 256 bits. The AEAD tag length (128 bits) and the commitment length (Nh octets, 256 bits for HKDF-SHA-256 and 512 for TurboSHAKE-256) provide at least 128-bit security against forgery and collision attacks. The snapshot authenticator provides file-level integrity at the same level (Section 5.8.7).

The Encode function is injective, so step_bindings are unambiguous; injectivity extends to over-large fields via the reserved 0xFFFF digest prefix, as established in Section 5.5 (raAE Construction Requirements) and Section 4.3 (The KDF Combiner) of [I-D.sullivan-cfrg-raae].

The reserved value has no effect on any current encoding. Every field that SAFE encodes today is below 0xFFFF octets, so all existing encodings, step_bindings, keys, commitments, and the test vectors in Appendix G are byte-identical to those produced before 0xFFFF was reserved. Only a field exceeding 0xFFFE octets takes the digest path; no such field exists in the current registrations. No current test vector changes.

Per-segment nonces are unique within a file by construction. In random mode (nonce_mode 0x00) each nonce is generated via SafeRandom. In derived mode (nonce_mode 0x01) each segment's derived nonce is distinct because it is bound to the segment index (Section 5.8.4). The relevant birthday bound for nonce collisions is across files or across segment rewrites under the same payload key. See Section 8.14 for per-AEAD analysis.

8.17. Downgrade Resistance

SAFE has no algorithm negotiation: the Encryptor selects encryption_parameters unilaterally, and the Decryptor either accepts them or fails. This downgrade-resistance property covers only the suite options bound into encryption_parameters (Section 5.5): aead_id, segment_size_be, kdf_id, snap_id, and epoch_length_u8. An active attacker who modifies any of these (e.g., substituting a weaker AEAD) changes the derived KEK (Section 5.7.1) and payload keys (Section 5.8.2), causing CEK unwrapping or segment decryption to fail. Forging a valid SAFE object with altered suite options requires the attacker to also hold valid credentials for the target LOCK.

Representation options (Lock-Encoding and Data-Encoding) fall outside encryption_parameters and outside this binding. An attacker who transcodes an object between armored and binary form, or between readable and armored LOCK encoding, changes no cryptographic value, since the encodings are lossless transcodings of one another (Section 3). Tampering with representation options alone is unauthenticated but safe: it cannot force acceptance of a weaker suite, and any change to the underlying bytes beyond re-encoding is still caught by the authentication that covers the suite options.

Decryptors SHOULD enforce a locally configured allowlist of acceptable encryption parameters. Rejecting algorithms outside the allowlist limits the attack surface to supported primitives.

The symmetric components of SAFE (HPKE export-only key schedules, SafeDerive-based KEK derivation, commitment prefixes, and AEAD encryption) are not vulnerable to quantum attacks. Grover's algorithm provides at most a quadratic speedup against symmetric primitives, leaving all symmetric operations at or above 128-bit security. The post-quantum migration surface is limited to KEM selection.

A deployment using KDF=turboshake256 eliminates all SHA-256 dependencies: per [I-D.ietf-hpke-hpke], all KEMs use that KDF internally.

9. Privacy Considerations

This section addresses the privacy properties of SAFE per [RFC6973]. SAFE is primarily a data-at-rest format; it does not define a transport protocol, so many [RFC6973] considerations (correlation by IP, traffic analysis of flows) do not apply directly.

SAFE-encoded data reveals the following metadata to passive observers: approximate payload size (from Base64 or binary length), recipient count (from the number of LOCK blocks), and, when key identifiers are present, linkability across files encrypted to the same recipient (Section 8.7). Hinted mode (Section 8.7.1) reduces linkability; anonymous mode eliminates key-identifier-based linking at the cost of increased trial decryption. Auth-mode sender identifiers (sid, shint) create analogous sender linkability (Section 8.7.2). Applications concerned about metadata leakage SHOULD pad payloads and SHOULD prefer hinted or anonymous modes.

Beyond the metadata listed above, the step types and KEM identifiers in each LOCK are visible in cleartext, revealing the authentication factors required (e.g., passphrase, X25519, ML-KEM-768). In multi-recipient files, the set of LOCK blocks reveals co-recipient relationships. In derived mode, segment rewrites at the same index produce identical ciphertext when the plaintext is unchanged, leaking equality (Section 8.10.2). A comprehensive [RFC6973] privacy analysis is deferred to a future revision.

10. IANA Considerations

10.1. SAFE AEAD Identifiers Registry

IANA is requested to create a SAFE AEAD Identifiers registry. Registration policy is Specification Required. Designated Experts should verify that proposed AEADs provide [RFC5116] semantics with a 16-octet authentication tag and Nk of 32 octets (SAFE derives all keys at 256 bits). Experts MUST reject registrations where Nk is not 32. Identifiers MUST consist of lowercase ASCII letters, digits, and hyphens, and MUST NOT exceed 255 octets. Identifier comparison is byte-exact; see Section 5.5. Each entry MUST include a Binding Name: an informative registry name, taken from the IANA AEAD Algorithms registry [RFC5116] when the algorithm is registered there. The value bound into encryption_parameters and payload_info is the numeric AEAD ID, not the Binding Name. The algorithm MUST be appropriate for general-purpose use in encrypted data formats.

Initial entries:

Table 15
Identifier Binding Name AEAD ID Nk Nn MRAE Reference Change Controller
aes-256-gcm AEAD_AES_256_GCM 0x0002 32 12 No [NIST-SP-800-38D] IETF
chacha20-poly1305 AEAD_CHACHA20_POLY1305 0x001D 32 12 No [RFC8439] IETF
aes-256-gcm-siv AEAD_AES_256_GCM_SIV 0x001F 32 12 Yes [RFC8452] IETF
aegis-256 AEAD_AEGIS_256 0x0021 32 32 No [I-D.irtf-cfrg-aegis-aead] IETF
aegis-256x2 AEAD_AEGIS_256X2 0x0024 32 32 No [I-D.irtf-cfrg-aegis-aead] IETF

The AEAD ID is the uint16 code point entered into both payload_info (Section 5.8.2) and encryption_parameters (Section 5.5); the Binding Name is informative. The AEAD ID values for aes-256-gcm-siv, aegis-256, and aegis-256x2 are early allocations and may change before publication; a change would alter previously written commitments.

Nk/Nn are key/nonce sizes in octets. Nn is required to compute segment boundaries (Section 5.8.4). "MRAE" indicates misuse-resistant authenticated encryption ([RFC9771]). Epoch length is not part of this registry: the selected SEAL named instantiation resolves it. See Section 5.8.1 and Section 5.8.3 for details.

The aegis-256 and aegis-256x2 Binding Names are the values requested by [I-D.irtf-cfrg-aegis-aead] and are provisional pending IANA action; if IANA assigns different names, this registry follows IANA.

10.2. SAFE KEM Identifiers Registry

IANA is requested to create a SAFE KEM Identifiers registry. This registry maps SAFE's string identifiers (used in kem= parameters) to HPKE KEM IDs. Registration policy is Specification Required. Designated Experts should verify that the KEM is registered in the IANA HPKE KEM Identifiers registry, is compatible with HPKE export-only mode (AEAD ID 0xFFFF) as specified in Section 5.6.3, that a specification for SPKI encoding of public keys is provided, and that every field a decoder must recover from the encoding (notably the KEM ciphertext) is at most 0xFFFE octets so that it is always encoded literally and never takes the bind-only digest path (Section 4.1.1). The Auth column indicates whether the KEM supports AuthEncap/AuthDecap for HPKE Auth mode. Encryptors MUST NOT include sid or shint with KEMs where Auth=No.

Initial entries:

Table 16
Identifier HPKE KEM ID Encap Size Auth SPKI Encoding Change Controller
x25519 0x0020 32 octets Yes [RFC8410] IETF
p-256 0x0010 65 octets Yes [RFC5480] IETF
ml-kem-768 0x0041 1088 octets No [I-D.ietf-lamps-kyber-certificates] IETF

HPKE KEM IDs are defined in the IANA HPKE KEM Identifiers registry established by [RFC9180]. Identifiers MUST consist of lowercase ASCII letters, digits, and hyphens, and MUST NOT exceed 255 octets. Identifier comparison is byte-exact; see Section 5.5.

10.3. SAFE KDF Identifiers Registry

IANA is requested to create a SAFE KDF Identifiers registry. Registration policy is Specification Required. Designated Experts should verify that identifiers consist of lowercase ASCII letters, digits, and hyphens, do not exceed 255 octets, that the underlying KDF provides at least 128-bit security, and that the entry references a KDF registered in the HPKE KDF Identifiers registry (Section 7.2 of [RFC9180]). Per Section 5.5, identifier comparison is byte-exact.

Each entry MUST reference a KDF registered in the HPKE KDF Identifiers registry. Each entry MUST include a Binding Name: an informative registry name naming the KDF construction. The value bound into encryption_parameters (Section 5.5) and payload_info is the numeric HPKE KDF ID, not the Binding Name.

Two-stage KDFs provide Extract(salt, ikm), Expand(prk, info, L), and Nh. Single-stage KDFs ([I-D.ietf-hpke-pq]) provide Derive(ikm, L).

The Nh column specifies the output size in octets used for the commitment and the snapshot authenticator's key. Nh is the KDF's output size: 32 octets for HKDF-SHA-256 and 64 octets for TurboSHAKE-256.

The HPKE KDF ID is the uint16 code point entered into both payload_info (Section 5.8.2) and encryption_parameters (Section 5.5); the Binding Name is informative. The Class column determines which SafeDerive instantiation is used (see Section 5.4).

The CONFIG field is named "KDF" and its tokens (hkdf-sha-256, turboshake256) are KDF identifiers, because the field selects a KDF (and its underlying hash function) rather than a bare hash algorithm.

All conforming implementations MUST implement hkdf-sha-256, which is the default when KDF is omitted from the SAFE CONFIG. Implementations MAY implement turboshake256.

Initial entries:

Table 17
Identifier Binding Name HPKE KDF ID Class Nh Reference Change Controller
hkdf-sha-256 HKDF-SHA-256 0x0001 two-stage 32 [RFC5869] IETF
turboshake256 TurboSHAKE-256 0x0013 single-stage 64 [I-D.ietf-hpke-pq] IETF

The hkdf-sha-256 Binding Name, "HKDF-SHA-256", differs from the "HKDF-SHA256" spelling in the HPKE KDF Identifiers registry. The Binding Name is informative and its spelling has no binding consequence; the value entered into encryption_parameters and payload_info is the numeric HPKE KDF ID 0x0001.

The turboshake256 HPKE KDF ID 0x0013 is an early allocation per [I-D.ietf-hpke-pq] and may change before that document is published; a change would alter previously written commitments.

10.4. SAFE Step Names Registry

IANA is requested to create a SAFE Step Names registry. Each registration defines a step type conforming to the interface in Section 5.6. The registry has the following columns:

Step Name:

Unique ASCII identifier for the step type (e.g., "pass", "hpke").

Parameters Grammar:

ABNF grammar for step-specific parameters in the token, or "None" if no parameters.

Inputs:

Description of required inputs (e.g., "user passphrase, salt", "recipient private key, kemct").

Secret Length:

MUST be 32 octets for all registered steps.

Reference:

Document specifying the step's derivation algorithm.

Registration policy is Specification Required. Designated Experts MUST verify:

  • The derivation algorithm is deterministic and produces exactly 32 octets

  • Parameter names do not conflict with existing registrations

  • The specification provides complete implementation guidance including the Encode binding form

  • Each field the step requires a decoder to recover from the token is marked as recoverable and is at most 0xFFFE octets, so it is always encoded literally and never takes the bind-only digest path (Section 4.1.1)

Step names MUST match the grammar 1*(ALPHA / DIGIT / "-") and MUST NOT exceed 255 octets. Per Section 5.5, step name comparison is byte-exact.

Initial entries:

Table 18
Step Name Parameters Grammar Inputs Secret Length Reference Change Controller
pass kdf, salt, (t, p, m for argon2id; c for pbkdf2) passphrase, salt 32 octets Section 5.6.2 IETF
hpke kem, kemct, id, (sid, shint for auth) private key, kemct 32 octets Section 5.6.3 IETF

The pass step's algorithm variant (argon2id or pbkdf2) is specified in the step descriptor's kdf parameter. The default KDF is argon2id; pbkdf2 is available for environments where policy prohibits Argon2id.

The hpke step additionally requires the step binding itself as input. When sid or shint is present, the sender's private key (for encryption) or public key (for decryption) is also required. Supported kem values are defined in Section 5.6.3.2; key identifier computation is defined in Section 5.6.3. hpke(...) with sid or shint is OPTIONAL and limited to DHKEM-based KEMs (x25519, p-256).

Future registrations MAY define additional step types (e.g., hardware token, Oblivious Pseudorandom Function (OPRF)) or variant algorithms for existing step names (subject to Designated Expert review for interoperability impact). A registration request MUST include:

  • Step Name and Parameters Grammar (ABNF)

  • Complete list of Inputs with their sources

  • Derivation algorithm producing exactly 32 octets

  • Definition of any step-specific parameters (name, encoding, semantics)

  • Security considerations for the step type

See Appendix I for an illustrative example.

10.5. SAFE Config Options Registry

IANA is requested to create a SAFE Config Options registry. Each registration defines a CONFIG field name and the registry or value set that defines its legal values. The registry has the following columns:

Field Name:

Case-sensitive ASCII field name used in SAFE CONFIG.

Value Definition:

Registry or fixed set of values allowed for the field.

Scope:

One of "suite" or "representation". A suite option is cryptographically bound through encryption_parameters (Section 5.5) and thus the KEK. A representation option is presentational and binds nothing, selecting only how the SAFE object is serialized.

Reference:

Document specifying the field and its semantics.

Registration policy is Specification Required. Designated Experts MUST verify that the field name does not conflict with existing registrations, that the specification defines default behavior when the field is absent, and that the value definition is unambiguous.

Initial entries:

Table 19
Field Name Value Definition Scope Reference Change Controller
AEAD SAFE AEAD Identifiers registry suite Section 10.1 IETF
Profile SEAL-simple, SEAL-attachment, SEAL-attachment-small, SEAL-editable, SEAL-memory, SEAL-compact suite Section 5.8.1 IETF
KDF SAFE KDF Identifiers registry suite Section 10.3 IETF
Lock-Encoding armored (default), readable representation Section 6.2 IETF
Data-Encoding armored, binary (default) representation Section 6.3 IETF

The default encoding is "binary" when the Data-Encoding field is omitted. The default Lock-Encoding is "armored" when omitted. The default Profile is SEAL-editable. Profile values are fixed by this specification and [I-D.sullivan-cfrg-raae]; adding new values requires a Standards Track document update.

10.6. SAFE Block Types Registry

IANA is requested to create a SAFE Block Types registry. This registry lists the block types that may appear in SAFE-encoded data, identified by their fence markers. The registry has the following columns:

Block Type:

The block type name as it appears in fence markers (e.g., "CONFIG", "LOCK", "DATA").

Fence Marker:

The opening fence marker string (e.g., "-----BEGIN SAFE CONFIG-----").

Ignorable:

"Yes" if Decryptors MAY skip unrecognized instances of this block type without failing; "No" if Decryptors MUST fail when encountering an unrecognized block of this type.

Reference:

Document defining the block's semantics.

Registration policy is Specification Required. Designated Experts MUST verify:

  • The block type name does not conflict with existing registrations

  • The specification clearly defines the block's syntax and semantics

  • Blocks marked Ignorable=Yes do not affect security or correctness if omitted

Block type names MUST consist of uppercase ASCII letters and hyphens and MUST NOT exceed 32 octets.

Initial entries:

Table 20
Block Type Fence Marker Ignorable Reference Change Controller
CONFIG -----BEGIN SAFE CONFIG----- No Section 6.1 IETF
LOCK -----BEGIN SAFE LOCK----- No Section 6.2 IETF
DATA -----BEGIN SAFE DATA----- No Section 6.3.1 IETF

All initial block types are critical (Ignorable=No). Future extensions MAY register new block types with Ignorable=Yes for optional features such as detached signatures, metadata, or recipient hints.

10.7. Media Type Registration

IANA is requested to register the following media type per [RFC6838]:

Type name:

application

Subtype name:

safe

Required parameters:

None

Optional parameters:

None

Encoding considerations:

Binary or 7bit. Binary-encoded SAFE data (the default) have ASCII headers followed by raw binary payload data. Armored-encoded SAFE data consist of ASCII printable characters and line feeds, with Base64 encoding for payload data.

Security considerations:

SAFE-encoded data contain encrypted content. See Section 8 of this document.

Interoperability considerations:

SAFE headers use PEM-style fence markers; the payload is binary or Base64 armored (Section 6.3). Line wrapping of Base64 content is permitted; Decryptors MUST accept any line length.

Published specification:

This document

Applications that use this media type:

File encryption, secure file sharing, encrypted backups

Fragment identifier considerations:

None

Additional information:

Deprecated alias names for this type: None

Magic number(s): Files begin with "-----BEGIN SAFE" (ASCII)

File extension(s): .safe

Macintosh file type code(s): None

Person & email address to contact for further information:

N. Sullivan (nicholas.sullivan+ietf@gmail.com)

Intended usage:

COMMON

Restrictions on usage:

None

Author:

See Authors' Addresses section

Change controller:

IETF

11. References

11.1. Normative References

[I-D.ietf-hpke-hpke]
Barnes, R., Bhargavan, K., Lipp, B., and C. A. Wood, "Hybrid Public Key Encryption", Work in Progress, Internet-Draft, draft-ietf-hpke-hpke-04, , <https://datatracker.ietf.org/doc/html/draft-ietf-hpke-hpke-04>.
[I-D.ietf-hpke-pq]
Barnes, R. and D. Connolly, "Post-Quantum and Post-Quantum/Traditional Hybrid Algorithms for HPKE", Work in Progress, Internet-Draft, draft-ietf-hpke-pq-05, , <https://datatracker.ietf.org/doc/html/draft-ietf-hpke-pq-05>.
[I-D.ietf-lamps-kyber-certificates]
Turner, S., Kampanakis, P., Massimo, J., and B. Westerbaan, "Internet X.509 Public Key Infrastructure - Algorithm Identifiers for the Module-Lattice-Based Key-Encapsulation Mechanism (ML-KEM)", Work in Progress, Internet-Draft, draft-ietf-lamps-kyber-certificates-11, , <https://datatracker.ietf.org/doc/html/draft-ietf-lamps-kyber-certificates-11>.
[I-D.irtf-cfrg-aegis-aead]
Denis, F. and S. Lucas, "The AEGIS Family of Authenticated Encryption Algorithms", Work in Progress, Internet-Draft, draft-irtf-cfrg-aegis-aead-18, , <https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-aegis-aead-18>.
[I-D.sullivan-cfrg-raae]
Sullivan, N., "Random-Access Authenticated Encryption", Work in Progress, Internet-Draft, draft-sullivan-cfrg-raae-02, , <https://datatracker.ietf.org/doc/html/draft-sullivan-cfrg-raae-02>.
[NIST-SP-800-38D]
Dworkin, M., "Recommendation for Block Cipher Modes of Operation: Galois/Counter Mode (GCM) and GMAC", NIST Special Publication 800-38D, , <https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf>.
[RFC2119]
Bradner, S., "Key words for use in RFCs to Indicate Requirement Levels", BCP 14, RFC 2119, DOI 10.17487/RFC2119, , <https://www.rfc-editor.org/rfc/rfc2119>.
[RFC4648]
Josefsson, S., "The Base16, Base32, and Base64 Data Encodings", RFC 4648, DOI 10.17487/RFC4648, , <https://www.rfc-editor.org/rfc/rfc4648>.
[RFC5116]
McGrew, D., "An Interface and Algorithms for Authenticated Encryption", RFC 5116, DOI 10.17487/RFC5116, , <https://www.rfc-editor.org/rfc/rfc5116>.
[RFC5234]
Crocker, D., Ed. and P. Overell, "Augmented BNF for Syntax Specifications: ABNF", STD 68, RFC 5234, DOI 10.17487/RFC5234, , <https://www.rfc-editor.org/rfc/rfc5234>.
[RFC5480]
Turner, S., Brown, D., Yiu, K., Housley, R., and T. Polk, "Elliptic Curve Cryptography Subject Public Key Information", RFC 5480, DOI 10.17487/RFC5480, , <https://www.rfc-editor.org/rfc/rfc5480>.
[RFC5869]
Krawczyk, H. and P. Eronen, "HMAC-based Extract-and-Expand Key Derivation Function (HKDF)", RFC 5869, DOI 10.17487/RFC5869, , <https://www.rfc-editor.org/rfc/rfc5869>.
[RFC6838]
Freed, N., Klensin, J., and T. Hansen, "Media Type Specifications and Registration Procedures", BCP 13, RFC 6838, DOI 10.17487/RFC6838, , <https://www.rfc-editor.org/rfc/rfc6838>.
[RFC8018]
Moriarty, K., Ed., Kaliski, B., and A. Rusch, "PKCS #5: Password-Based Cryptography Specification Version 2.1", RFC 8018, DOI 10.17487/RFC8018, , <https://www.rfc-editor.org/rfc/rfc8018>.
[RFC8174]
Leiba, B., "Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words", BCP 14, RFC 8174, DOI 10.17487/RFC8174, , <https://www.rfc-editor.org/rfc/rfc8174>.
[RFC8410]
Josefsson, S. and J. Schaad, "Algorithm Identifiers for Ed25519, Ed448, X25519, and X448 for Use in the Internet X.509 Public Key Infrastructure", RFC 8410, DOI 10.17487/RFC8410, , <https://www.rfc-editor.org/rfc/rfc8410>.
[RFC8439]
Nir, Y. and A. Langley, "ChaCha20 and Poly1305 for IETF Protocols", RFC 8439, DOI 10.17487/RFC8439, , <https://www.rfc-editor.org/rfc/rfc8439>.
[RFC8452]
Gueron, S., Langley, A., and Y. Lindell, "AES-GCM-SIV: Nonce Misuse-Resistant Authenticated Encryption", RFC 8452, DOI 10.17487/RFC8452, , <https://www.rfc-editor.org/rfc/rfc8452>.
[RFC9106]
Biryukov, A., Dinu, D., Khovratovich, D., and S. Josefsson, "Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications", RFC 9106, DOI 10.17487/RFC9106, , <https://www.rfc-editor.org/rfc/rfc9106>.
[RFC9180]
Barnes, R., Bhargavan, K., Lipp, B., and C. Wood, "Hybrid Public Key Encryption", RFC 9180, DOI 10.17487/RFC9180, , <https://www.rfc-editor.org/rfc/rfc9180>.

11.2. Informative References

[AEA]
Apple Inc., "Apple Encrypted Archive (AEA)", , <https://support.apple.com/guide/security/protecting-app-access-to-user-data-secddd150c21/web>.
[AGE]
Valsorda, F., "The age file encryption format", , <https://age-encryption.org/v1>.
[AGE-COMMIT]
Stäuble, M., "Actually Good Encryption? Confusing Users by Changing Nonces", ETH Zürich Semester Project, , <https://ethz.ch/content/dam/ethz/special-interest/infk/inst-infsec/appliedcrypto/education/theses/semester-project_mirco-stauble.pdf>.
[FLOE]
Fábrega, A., Len, J., Ristenpart, T., and G. Rubin, "Random-Access AEAD for Fast Lightweight Online Encryption", IACR ePrint 2025/2275, , <https://eprint.iacr.org/2025/2275>.
[I-D.ietf-ohai-chunked-ohttp]
Pauly, T. and M. Thomson, "Chunked Oblivious HTTP Messages", Work in Progress, Internet-Draft, draft-ietf-ohai-chunked-ohttp-08, , <https://datatracker.ietf.org/doc/html/draft-ietf-ohai-chunked-ohttp-08>.
[I-D.irtf-cfrg-aead-limits]
Günther, F., Thomson, M., and C. A. Wood, "Usage Limits on AEAD Algorithms", Work in Progress, Internet-Draft, draft-irtf-cfrg-aead-limits-11, , <https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-aead-limits-11>.
[RFC5652]
Housley, R., "Cryptographic Message Syntax (CMS)", STD 70, RFC 5652, DOI 10.17487/RFC5652, , <https://www.rfc-editor.org/rfc/rfc5652>.
[RFC6973]
Cooper, A., Tschofenig, H., Aboba, B., Peterson, J., Morris, J., Hansen, M., and R. Smith, "Privacy Considerations for Internet Protocols", RFC 6973, DOI 10.17487/RFC6973, , <https://www.rfc-editor.org/rfc/rfc6973>.
[RFC7516]
Jones, M. and J. Hildebrand, "JSON Web Encryption (JWE)", RFC 7516, DOI 10.17487/RFC7516, , <https://www.rfc-editor.org/rfc/rfc7516>.
[RFC8551]
Schaad, J., Ramsdell, B., and S. Turner, "Secure/Multipurpose Internet Mail Extensions (S/MIME) Version 4.0 Message Specification", RFC 8551, DOI 10.17487/RFC8551, , <https://www.rfc-editor.org/rfc/rfc8551>.
[RFC8937]
Cremers, C., Garratt, L., Smyshlyaev, S., Sullivan, N., and C. Wood, "Randomness Improvements for Security Protocols", RFC 8937, DOI 10.17487/RFC8937, , <https://www.rfc-editor.org/rfc/rfc8937>.
[RFC9497]
Davidson, A., Faz-Hernandez, A., Sullivan, N., and C. A. Wood, "Oblivious Pseudorandom Functions (OPRFs) Using Prime-Order Groups", RFC 9497, DOI 10.17487/RFC9497, , <https://www.rfc-editor.org/rfc/rfc9497>.
[RFC9578]
Celi, S., Davidson, A., Valdez, S., and C. A. Wood, "Privacy Pass Issuance Protocols", RFC 9578, DOI 10.17487/RFC9578, , <https://www.rfc-editor.org/rfc/rfc9578>.
[RFC9580]
Wouters, P., Ed., Huigens, D., Winter, J., and Y. Niibe, "OpenPGP", RFC 9580, DOI 10.17487/RFC9580, , <https://www.rfc-editor.org/rfc/rfc9580>.
[RFC9605]
Omara, E., Uberti, J., Murillo, S. G., Barnes, R., Ed., and Y. Fablet, "Secure Frame (SFrame): Lightweight Authenticated Encryption for Real-Time Media", RFC 9605, DOI 10.17487/RFC9605, , <https://www.rfc-editor.org/rfc/rfc9605>.
[RFC9771]
Bozhko, A., Ed., "Properties of Authenticated Encryption with Associated Data (AEAD) Algorithms", RFC 9771, DOI 10.17487/RFC9771, , <https://www.rfc-editor.org/rfc/rfc9771>.
[STREAM]
Hoang, V. T., Reyhanitabar, R., Rogaway, P., and D. Vizár, "Online Authenticated-Encryption and its Nonce-Reuse Misuse-Resistance", IACR ePrint 2015/189, , <https://eprint.iacr.org/2015/189>.
[TINK]
Tink Authors, "Streaming AEAD (AES-GCM-HKDF-STREAMING)", , <https://developers.google.com/tink/streaming-aead/aes_gcm_hkdf_streaming>.
[W3C.webauthn-3]
"Web Authentication: An API for accessing Public Key Credentials - Level 3", W3C WD webauthn-3, W3C webauthn-3, <https://www.w3.org/TR/webauthn-3/>.

Appendix A. Examples

This appendix is informative.

Note: The examples in this section illustrate structure and formatting only. The Base64 values are placeholders and do not represent valid cryptographic outputs. Implementers requiring test vectors with known inputs and outputs should consult the Test Vectors appendix (Appendix G).

A minimal object with readable LOCK format (Lock-Encoding set to readable; aes-256-gcm, commitment prefix):

-----BEGIN SAFE CONFIG-----
Lock-Encoding: readable
Data-Encoding: armored
-----END SAFE CONFIG-----
-----BEGIN SAFE LOCK-----
Step: pass(kdf=argon2id, salt=c2FsdHZhbHVlMTIzNDU2Nzg=)
Encrypted-CEK:
  MTIzNDU2Nzg5MDEyM0FCQ0RFRkdISUpLTE1OT1BRUlNU
  VVZXWFlaYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNA==
-----END SAFE LOCK-----
-----BEGIN SAFE DATA-----
cGxhY2Vob2xkZXJjZWtjb21taXRtZW50aGFzaDEyMzQ1Njc4YWJjZGVmZ2hpamts
bW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWjAxMjM0NTY3
ODkrLz09
-----END SAFE DATA-----

A LOCK block encoded as armored (default Lock-Encoding):

-----BEGIN SAFE CONFIG-----
Data-Encoding: armored
-----END SAFE CONFIG-----
-----BEGIN SAFE LOCK-----
U3RlcDogcGFzcyhrZGY9YXJnb24yaWQsc2FsdD1jMkZzZEhaaGJIVmxNVEl6TkRV
Mk56Zz0pCkVuY3J5cHRlZC1DRUs6IE1USXpORFUyTnpnNU1ERXlNMEZDUTBSRlJr
ZElTVXBMVEUxT1QxQlJVbE5VVlZaWFdGbGFZV0pqWkdWbVoyaHBhbXRzYlc1dmNI
RnljM1IxZG5kNGVYb3dNVEl6TkE9PQ==
-----END SAFE LOCK-----
-----BEGIN SAFE DATA-----
YmFzZTY0bG9ja2V4YW1wbGVkYXRhY2h1bmtwbGFjZWhvbG
RlcmV4YW1wbGVkYXRhMTIzNDU2
-----END SAFE DATA-----

HPKE recipient in armored format (derived from the readable example above):

-----BEGIN SAFE CONFIG-----
Data-Encoding: armored
-----END SAFE CONFIG-----
-----BEGIN SAFE LOCK-----
U3RlcDogaHBrZShrZW09eDI1NTE5LGlkPVppNm1Rblk1ajhwSVlxM282cldnTnc9
PSxrZW1jdD1ZV0pqWkdWbVoyaHBhbXRzYlc1dmNIRnljM1IxZG5kNGVYb3hNak0w
TlRZM09Ea3cpCkVuY3J5cHRlZC1DRUs6IGNHRnpjM2R2Y21ReE1qTTBOVFkzT0Rr
d1lXSmpaR1ZtWjJocGFtdHNiVzV2Y0hGeWMzUjFkbmQ0ZVhveE1qTTBOVFkzT0Rr
d1lXSmpaR1ZtWjJocGFnPT0=
-----END SAFE LOCK-----
-----BEGIN SAFE DATA-----
aHBrZWV4YW1wbGVjZWtjb21taXRtZW50aGFzaHZhbHVlMTJWR2hwY3lCcGN5QmhJ
SE5oYlhCc1pTQmxibU55ZVhCMFpXUWdjR0Y1Ykc5aFpDQjNhWFJvSUcxMWJIUnBj
R3hsSUdOb2RXNXJjdz09
-----END SAFE DATA-----

A HPKE recipient in readable format (AEAD omitted, uses default aes-256-gcm with commitment prefix):

-----BEGIN SAFE CONFIG-----
Lock-Encoding: readable
Data-Encoding: armored
-----END SAFE CONFIG-----
-----BEGIN SAFE LOCK-----
Step: hpke(kem=x25519, id=Zi6mQnY5j8pIYq3o6rWgNw==,
  kemct=YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY3ODkw)
Encrypted-CEK:
  cGFzc3dvcmQxMjM0NTY3ODkwYWJjZGVmZ2hpamtsbW5vcHFy
  c3R1dnd4eXoxMjM0NTY3ODkwYWJjZGVmZ2hpag==
-----END SAFE LOCK-----
-----BEGIN SAFE DATA-----
aHBrZWV4YW1wbGVjZWtjb21taXRtZW50aGFzaHZhbHVlMTJWR2hwY3lCcGN5QmhJ
SE5oYlhCc1pTQmxibU55ZVhCMFpXUWdjR0Y1Ykc5aFpDQjNhWFJvSUcxMWJIUnBj
R3hsSUdOb2RXNXJjdz09
-----END SAFE DATA-----

AEGIS-256 with KDF: turboshake256 (non-default AEAD and KDF, 32-octet key identifier, commitment prefix in DATA):

-----BEGIN SAFE CONFIG-----
AEAD: aegis-256
KDF: turboshake256
Lock-Encoding: readable
Data-Encoding: armored
-----END SAFE CONFIG-----
-----BEGIN SAFE LOCK-----
Step: hpke(kem=x25519,
  id=YWVnaXN0dXJib3NoYWtlaWRlbnRpZmllcjMyYnl0ZXM=,
  kemct=YWVnaXNrZW1jaXBoZXJ0ZXh0ZXhhbXBsZTEyMzQ1Njc4)
Encrypted-CEK:
  YWVnaXNub25jZTEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIz
  YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2
  Nzg5MGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6
-----END SAFE LOCK-----
-----BEGIN SAFE DATA-----
YWVnaXNub2NvbW1pdG1lbnRjaHVua2NpcGhlcnRleHRvbmx5ZXhhbXBsZWRhdGE=
-----END SAFE DATA-----

Anonymous X25519 recipient (trial decryption required):

-----BEGIN SAFE CONFIG-----
Lock-Encoding: readable
Data-Encoding: armored
-----END SAFE CONFIG-----
-----BEGIN SAFE LOCK-----
Step: hpke(kem=x25519,
  kemct=YW5vbnltb3VzZW5jYXBzdWxhdGVka2V5bWF0ZXJpYWwx
  MjM0NTY3ODkwYWJjZGVmZ2hpamtsbW5vcHFyc3R1dg==)
Encrypted-CEK:
  YW5vbnltb3VzZW5jcnlwdGVkY2VrZGF0YTEyMzQ1Njc4
  OTBhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMg==
-----END SAFE LOCK-----
-----BEGIN SAFE DATA-----
YW5vbnltb3VzY2VrY29tbWl0bWVudGhhc2h2YWx1ZXBheWxvYWRjaHVua2RhdGE=
-----END SAFE DATA-----

Hinted ML-KEM-768 recipient (4-digit hint for candidate filtering):

-----BEGIN SAFE CONFIG-----
Lock-Encoding: readable
Data-Encoding: armored
-----END SAFE CONFIG-----
-----BEGIN SAFE LOCK-----
Step: hpke(kem=ml-kem-768, hint=4217,
  kemct=bWxrZW1jaXBoZXJ0ZXh0d2l0aGhpbnRlZHJlY2lwaWVu
  dGZpbHRlcmluZ2V4YW1wbGVkYXRhMTIzNDU2Nzg5MA==)
Encrypted-CEK:
  aGludGVkZW5jcnlwdGVkY2VrZGF0YWV4YW1wbGUxMjM0
  NTY3ODkwYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo=
-----END SAFE LOCK-----
-----BEGIN SAFE DATA-----
aGludGVkY2VrY29tbWl0bWVudGhhc2h2YWx1ZXBheWxvYWRjaHVua2RhdGFoZXJl
-----END SAFE DATA-----

Two recipients, one passphrase-only and one HPKE (default aes-256-gcm):

-----BEGIN SAFE CONFIG-----
Lock-Encoding: readable
Data-Encoding: armored
-----END SAFE CONFIG-----
-----BEGIN SAFE LOCK-----
Step: pass(kdf=argon2id, salt=cHdkc2FsdDEyMzQ1Njc4OTA=)
Encrypted-CEK:
  cHdkbm9uY2UxMjM0NTY3ODkwYWJjZGVmZ2hpamtsbW5vcHFy
  c3R1dnd4eXowMTIzNDU2Nzg5MGFiY2RlZmdoaWo=
-----END SAFE LOCK-----
-----BEGIN SAFE LOCK-----
Step: hpke(kem=p-256, id=Z1d0u6QG0cB2a4nM3Kp2Ww==,
  kemct=QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlq
  a2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODkwYWJjZGVmZ2hp
  amtsbW5vcHFyc3R1dnd4eXo=)
Encrypted-CEK:
  aHBrZW5vbmNlMTIzNDU2Nzg5MGFiY2RlZmdoaWprbG1ub3Bx
  cnN0dXZ3eHl6MDEyMzQ1Njc4OTBhYmNkZWZnaGlq
-----END SAFE LOCK-----
-----BEGIN SAFE DATA-----
bXVsdGlyZWNpcGllbnRjZWtjb21taXRtZW50aGFzaHZhbHVlVFhWc2RHa3RjbVZq
YVhCcFpXNTBJR1Y0WVcxd2JHVWdkMmwwYUNCemFHRnlaV1FnY0dGNWJHOWhaQ0Js
Ym1OeWVYQjBaV1FnYjI1alpRPT0=
-----END SAFE DATA-----

Multi-step sequence with AND semantics (passphrase AND HPKE, chacha20-poly1305 with commitment prefix):

-----BEGIN SAFE CONFIG-----
AEAD: chacha20-poly1305
Lock-Encoding: readable
Data-Encoding: armored
-----END SAFE CONFIG-----
-----BEGIN SAFE LOCK-----
Step: pass(kdf=argon2id, salt=bXVsdGlzdGVwc2FsdDEyMzQ=)
Step: hpke(kem=x25519, id=eEF3bXlyT3BWbXpLUjRCdz09,
  kemct=bXVsdGlzdGVwZXhhbXBsZWtlbWNpcGhlcnRleHQxMjM0NTY3)
Encrypted-CEK:
  bXVsdGlzdGVwbm9uY2UxMjM0NTY3ODkwYWJjZGVmZ2hpamts
  bW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5MGFiYw==
-----END SAFE LOCK-----
-----BEGIN SAFE DATA-----
bXVsdGlzdGVwY2VrY29tbWl0bWVudGhhc2h2YWx1ZTEyMzQ1VW1WeGRXbHlaWE1n
WW05MGFDQndZWE56ZDI5eVpDQkJUa1FnV0RJMU5URTVJSEJ5YVhaaGRHVWdhMlY1
SUhSdklHUmxZM0o1Y0hRPQ==
-----END SAFE DATA-----

Note: The armored LOCK examples above use placeholder Base64 values. The armored payload is the Base64 of the readable LOCK per Section 6.2.2.

Two-passphrase step sequence (requires both passphrases to decrypt):

-----BEGIN SAFE CONFIG-----
Lock-Encoding: readable
-----END SAFE CONFIG-----
-----BEGIN SAFE LOCK-----
Step: pass(kdf=argon2id, salt=Zmlyc3RwYXNzd29yZHNhbHQ=)
Step: pass(kdf=pbkdf2, salt=c2Vjb25kcGFzc3dvcmRzYWx0)
Encrypted-CEK:
  dHdvcGFzc3dvcmRub25jZTEyMzQ1Njc4OTBhYmNkZWZnaGlq
  a2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODkwYWI=
-----END SAFE LOCK-----

Hybrid post-quantum step sequence (X25519 AND ML-KEM-768, default aes-256-gcm with commitment prefix):

-----BEGIN SAFE CONFIG-----
Lock-Encoding: readable
Data-Encoding: armored
-----END SAFE CONFIG-----
-----BEGIN SAFE LOCK-----
Step: hpke(kem=x25519, id=cHF6RmlucHJpbnQxMjM0NTY3,
  kemct=eDI1NTE5a2VtY2lwaGVydGV4dDEyMzQ1Njc4OTBhYmNkZWY=)
Step: hpke(kem=ml-kem-768, id=bWxrZW1maW5nZXJwcmludDEyMw==,
  kemct=bWxrZW03NjhrZW1jaXBoZXJ0ZXh0ZXh0cmVtZWx5bG9uZ2Jh
  c2U2NGVuY29kZWRkYXRhYXBwcm94aW1hdGVseTEwODhvY3Rl
  dHNmb3JwcXNlY3VyaXR5dGhpc2lzZHVtbXlkYXRhZm9yZGVt
  ...
  NzY4a2VtY2lwaGVydGV4dGVuY2Fwc3VsYXRpb25kYXRh)
Encrypted-CEK:
  aHlicmlkbm9uY2UxMjM0NTY3ODkwYWJjZGVmZ2hpamtsbW5v
  cHFyc3R1dnd4eXowMTIzNDU2Nzg5MGFiY2RlZmc=
-----END SAFE LOCK-----
-----BEGIN SAFE DATA-----
aHlicmlkcHFjZWtjb21taXRtZW50aGFzaHZhbHVlMTIzNDU2VUc5emRDMXhkV0Z1
ZEhWdElHaDVZbkpwWkNCbGVHRnRjR3hsSUdSbGJXOXVjM1J5WVhScGJtY2dZMjl0
WW1sdVpXUWdXREkxTlRFNUlHRnVaQ0JOVEMxTFJVMHQ=
-----END SAFE DATA-----

Appendix B. Implementation Guide

This appendix is informative. For a concise summary of the encryptor and decryptor flows, see Section 3.

B.1. Encryptor Processing

Encryptor processing proceeds in three phases: setup, recipient key wrapping, and content encryption.

During setup, the Encryptor:

  1. Selects a Profile and AEAD algorithm (or accepts defaults)

  2. Emits a SAFE CONFIG block if using non-default values

  3. Generates a random 32-octet CEK: SafeRandom(32, "SAFE-CEK")

  4. Generates a 32-octet salt: SafeRandom(32, "SAFE-SALT")

Encryptors should generate a fresh CEK for each file. When the same CEK is reused across files, the per-file salt ensures payload keys are unique: each salt produces distinct commitment, payload_key, snap_key, and nonce_base values via Section 5.8.2.

For each recipient, the Encryptor wraps the CEK by:

  1. Emitting Step lines with required step parameters (salt for pass steps, kemct for hpke steps)

  2. Generating a fresh lock_nonce: SafeRandom(Nn, "SAFE-LOCK-NONCE")

  3. Deriving step secrets and computing the KEK per Section 5.7.1

  4. Emitting the Encrypted-CEK field

To encrypt content, the Encryptor:

  1. Derives payload_key from the CEK and payload_info (which includes the per-file salt) per Section 5.8.2

  2. Splits the plaintext into Segment-Size segments (N segments total)

  3. Encrypts each segment per the SEAL segment procedure (Section 4.8 of [I-D.sullivan-cfrg-raae])

What the Encryptor stores per segment follows the resolved nonce mode and snap_id (Section 5.8.4). Stored metadata is placed per the layout:

  • Linear layout (Section 6.4.1): each encrypted segment is its ciphertext and tag, preceded by the nonce in random mode.

  • Aligned layout (Section 6.4.2): the header metadata array holds each segment's entry (Section 5.8.4), and ciphertext segments are at aligned offsets.

B.2. Decryptor Processing

Decryptor processing proceeds in three phases: configuration, CEK recovery, and content decryption.

During configuration, the Decryptor reads the SAFE CONFIG (if present) to learn non-default values and constructs encryption_parameters from the Profile, AEAD, and KDF per Section 5.5.

To recover the CEK, the Decryptor:

  1. Selects a LOCK block per Section 6.2.3

  2. Parses lock_nonce (first Nn octets) from the Encrypted-CEK field

  3. Evaluates steps to derive the KEK per Section 5.7.1

  4. Decrypts Encrypted-CEK to recover the 32-octet CEK

Encrypted-CEK size validation is specified in Section 6.2.

To decrypt content, the Decryptor reads the 32-octet salt leading the raAE header (Section 6.4), then derives payload_key from the CEK and payload_info (which includes that salt) per Section 5.8.2. It then decrypts each segment per the SEAL segment procedure (Section 4.8 of [I-D.sullivan-cfrg-raae]). The resolved nonce mode (Section 5.8.1) governs the nonce source. In random mode (nonce_mode 0x00) the Decryptor reads each nonce from the stored metadata. In derived mode (nonce_mode 0x01) it recomputes the nonce from the key schedule (Section 5.8.4). The location of the stored components depends on the layout:

  • Linear layout (Section 6.4.1): each encrypted segment contains its ciphertext and tag, preceded by the nonce in random mode.

  • Aligned layout (Section 6.4.2): the header metadata array holds each segment's entry (Section 5.8.4), and ciphertext segments are at aligned offsets.

See Appendix E for offset calculations.

Appendix C. Error Codes for Testing

This appendix is informative.

For interoperability testing, implementations may use the following error identifiers to categorize failures:

Table 21
Error Code Description When to Emit
ERR_UNSUPPORTED_AEAD Unknown AEAD algorithm Parsing SAFE CONFIG
ERR_UNSUPPORTED_KEM Unknown KEM identifier Parsing hpke(...) step
ERR_INVALID_PROFILE Invalid Profile value Parsing SAFE CONFIG
ERR_HPKE_NO_MATCH No matching private key Recipient discovery
ERR_HPKE_DECAP_FAILED HPKE decapsulation error CEK recovery
ERR_LOCK_AEAD_FAILED Encrypted-CEK decryption failed CEK recovery
ERR_PAYLOAD_AEAD_FAILED Segment decryption failed Content decryption
ERR_SEGMENT_OUT_OF_RANGE Segment index invalid Content decryption
ERR_MALFORMED_BASE64 Base64 decoding error Any Base64 field
ERR_DUPLICATE_FIELD Repeated field name Parsing SAFE CONFIG
ERR_DUPLICATE_PARAM Repeated step parameter Parsing Step lines
ERR_MISSING_SALT pass(...) without salt Parsing LOCK
ERR_MISSING_KEMCT hpke(...) without kemct Parsing LOCK
ERR_MULTIPLE_PASS_ONLY_LOCK Multiple same-variant pass-only LOCKs Discovery
ERR_NON_ASCII_HEADER Non-ASCII in header Parsing any header
ERR_RESOURCE_LIMIT Size/count limit exceeded Parsing any block
ERR_INVALID_SALT_LENGTH salt not exactly 32 octets Parsing salt
ERR_COMMITMENT_MISMATCH Commitment verification failed Key schedule
ERR_SNAPSHOT_MISMATCH Snapshot verification failed Integrity check
ERR_TRUNCATION Missing final segment or segment with is_final=0 at EOF Content decryption

Appendix D. Armored Data Arithmetic

This appendix is informative.

In armored mode, Decryptors compute the segment count N and final segment size from the Base64 payload length.

Let S_b64 be the payload string between the fences, len_b64 its length in characters, pad the number of trailing = signs (0, 1, or 2), and len_bin_total = 3 * floor(len_b64 / 4) - pad.

The header region is 32 (salt) + Nh (commitment) + Na octets, where Na is the snapshot value length: 2*Nh at snap_id 0x0001 and 0 at snap_id 0x0000 (Section 5.8.7). The encrypted segment region is len_bin_ciphertext = len_bin_total - 32 - Nh - Na.

Let B = Segment-Size and Nn = AEAD nonce length. In derived mode (nonce_mode 0x01) nonces are not stored, so the per-segment overhead excludes Nn. Define:

if nonce_mode == 0x01:   # derived mode
    C      = B + 16
    C_min  = 16           # minimum: tag only
else:                    # random mode (0x00)
    C      = Nn + B + 16
    C_min  = Nn + 16      # minimum: nonce + tag

N_nonfinal = floor( len_bin_ciphertext / C )
rem        = len_bin_ciphertext - N_nonfinal * C
if rem == 0:
    N       = N_nonfinal
    C_final = C
else if rem < C_min:
    reject as malformed
else:
    N       = N_nonfinal + 1
    C_final = rem

if nonce_mode == 0x01:   # derived mode
    L_final = C_final - 16
else:                    # random mode (0x00)
    L_final = C_final - Nn - 16

A decryptor decrypting segment index i computes octet offsets relative to the start of the encrypted segment region (after the salt, commitment, and snapshot value, when present):

seg_byte_start = i * C
seg_byte_len   = C if i < N - 1 else C_final
byte_start     = 32 + Nh + Na + seg_byte_start
byte_len       = seg_byte_len
char_start     = 4 * floor( byte_start / 3 )
char_end       = 4 * ceil( (byte_start + byte_len) / 3 )

For each segment index i, the Decryptor:

  1. Extracts S_b64[char_start:char_end]

  2. Base64-decodes to a temporary buffer tmp

  3. Computes skip = byte_start mod 3

  4. Selects encrypted_segment = tmp[skip : skip + byte_len]

  5. In random mode (nonce_mode 0x00): parses nonce_i = encrypted_segment[0:Nn] and ciphertext_i = encrypted_segment[Nn:]. In derived mode (nonce_mode 0x01): ciphertext_i = encrypted_segment and nonce_i is recomputed per Section 5.8.4

  6. Determines is_final = 1 if i == N - 1 else 0

  7. Constructs data_aad(i, is_final) per Section 5.8.8

  8. Computes key_i = segment_key(i) (Section 5.8.3)

  9. AEAD-opens ciphertext_i under key_i with nonce_i and data_aad

Appendix E. Selective Decryption

This appendix is informative.

To decrypt segment index i from a long object, a Decryptor first selects a candidate LOCK per Section 6.2.3. The decryptor constructs encryption_parameters (aead_id, Segment-Size, kdf_id, snap_id, epoch_length_u8) from the SAFE CONFIG or from defaults, parses lock_nonce from Encrypted-CEK, evaluates the step sequence to derive the KEK, and opens Encrypted-CEK to recover the CEK. The decryptor reads the 32-octet salt leading the raAE header (Section 6.4), then derives payload_key and snap_key from the CEK and payload_info (which includes that salt). It then locates segment i in the payload. No other segments need to be read or decoded. What the returned plaintext carries is the per-segment case of Section 5.8.7. Under snap_id 0x0001 it carries per-segment authenticity but not the whole-object snapshot guarantee, which there is verifiable only over the whole segment set. Under 0x0002 and 0x0003 the Decryptor covers that one segment by the authenticator's per-segment procedure (Section 5.8.7), so the returned plaintext carries snapshot integrity too (Section 6.4.2).

Under an aligned-layout Profile, read N and D from the header, then compute segment i's ciphertext offset as (D + i) × B. Segment i's metadata entry begins at header_len + 8 (N and D) + 32 (salt) + Nh (commitment) + Na (the snapshot value), plus E × Nh when snap_id is 0x0003, plus i × meta_len. Section 6.4.2 gives Na, E, and meta_len.

Under a linear-layout Profile the segments follow the raAE header in order, so segment i's ciphertext begins at header_len + 32 (salt) + Nh (commitment) + Na (the snapshot value) + i * C, where C is the per-segment stored size: Nn + B + 16 in random mode and B + 16 in derived mode. Under armored encoding the Decryptor must additionally compute the Base64 character window covering that range and decode only that window, as described below.

E.1. Example: Armored Selective Segment Decryption

Armored encoding is defined only over the linear layout (Section 6.3), so this example uses SEAL-editable, whose nonce mode is random (nonce_mode 0x00) and whose Segment-Size is 65536 (Section 5.8.1), with AES-256-GCM (Nn=12). Under a Profile whose nonce mode is derived, omit Nn from the per-segment sizes (C = B + 16 instead of Nn + B + 16).

Consider three segments: two full segments plus a 5000-octet final segment.

C       = Nn + B + 16 = 12 + 65536 + 16 = 65564 octets (full segment)
C_final = Nn + 5000 + 16 = 12 + 5000 + 16 = 5028 octets (final segment)
total_binary = 32 + 32 + 64 + 65564 + 65564 + 5028 = 136284 octets
Base64 len   = ceil(136284 / 3) * 4 = 181712 characters

To decrypt segment i=0, compute octet and character offsets (salt, commitment, and the snapshot value occupy the first 128 octets):

byte_start  = 128 + (0 * C) = 128
byte_len    = C = 65564
char_start  = floor(byte_start / 3) * 4 = 168
char_end    = ceil((byte_start + byte_len) / 3) * 4 = 87592
skip        = byte_start mod 3 = 2

Extract S_b64[168:87592], Base64-decode, then take 65564 octets as the encrypted segment starting at skip = 2 (discard the leading two octets). In random mode (nonce_mode 0x00), parse the first Nn octets (12 for AES-256-GCM) as the stored nonce, compute segment_key(i) (Section 5.8.3), and AEAD-open the remaining octets under segment_key(i) with the extracted nonce and segment AAD. In derived mode (nonce_mode 0x01), derive the nonce from nonce_base instead (Section 5.8.4).

Appendix F. Design Rationale

This appendix is informative.

SAFE's design choices reflect trade-offs between flexibility, performance, and simplicity. This section explains the rationale behind key architectural decisions.

F.1. Two-Tier Key Hierarchy

SAFE separates the Content-Encryption Key (CEK) from the Key-Encryption Key (KEK) to enable multi-recipient encryption without duplicating payload ciphertexts.

F.1.1. Benefits

A single CEK is generated once and used to encrypt the payload; each recipient's LOCK derives a KEK that wraps the same CEK. This design offers several advantages:

  1. Storage and bandwidth efficiency: Adding recipients requires only adding LOCK blocks (typically < 1 KB each), not duplicating the entire payload. For large files, this is critical.

  2. Key rotation: Recipients can be added or removed by re-wrapping the CEK under new KEKs without re-encrypting the payload.

  3. Operational flexibility: The CEK remains constant while KEKs rotate, simplifying key management.

F.1.2. Trade-offs

This design implies that all recipients share the same payload_key. Encryptors who require per-recipient payload keys (e.g., for fine-grained access control that survives CEK compromise) would need to encrypt multiple independent payloads.

If recipients directly decrypted the payload with their KEK, each recipient would require a distinct copy of the ciphertext, multiplying storage and bandwidth costs.

F.2. Minimal Segment AAD

In random nonce mode, segment associated data is Encode("SEAL-DATA", uint64(i), uint8(is_final)), binding each segment to its position and finality. In derived nonce mode the index and finality are bound in the nonce instead and omitted from the AAD (Section 5.8.8 is authoritative). Either way, suite parameters and LOCK-specific data are excluded from segment AAD.

F.2.1. Rationale

This choice prioritizes simplicity and O(1) random access:

  1. Selective decryption: payload_key already depends on payload_info, so segment AAD need not repeat the negotiated parameters. This avoids requiring every segment decryption to reference the SAFE CONFIG.

  2. Multi-recipient caching: Including LOCK-specific data (Step lines, kemct) would couple segment decryption to a specific LOCK, preventing efficient caching of payload_key across multiple recipients.

F.2.2. Security Properties

Suite and LOCK binding is indirect through the key hierarchy:

  • The KEK schedule binds encryption_parameters and all step_bindings in one SafeDerive call. Encrypted-CEK AEAD authenticates the CEK under this KEK.

  • The payload schedule binds payload_key to payload_info via SealDerive.

  • Each segment is bound to its index and finality flag, preventing reordering, splicing, truncation, and extension within a file (Section 5.8.8).

  • The snapshot authenticator (Section 5.8.7) binds every segment's per-segment input and the segment count, providing file-level integrity without per-segment decryption. Its construction and security are specified in Section 4.7 of [I-D.sullivan-cfrg-raae] (Snapshot Authenticator).

F.2.3. Alternative Designs Considered

An alternative design could include a "recipient_id" in segment AAD, but this would require additional per-recipient metadata and complicate multi-recipient scenarios. SAFE's choice favors performance and simplicity for the common case of single-recipient or trust-equivalent multi-recipient files, while accepting that ciphertext segments alone do not directly identify which LOCK unlocked them.

SAFE provides built-in truncation and extension detection via the is_final flag bound to each segment (Section 5.8.8). The final segment is marked with is_final=1, and all preceding segments use is_final=0. This design, inspired by the STREAM construction [STREAM], enables:

  • Truncation detection: a segment with is_final=0 and no successor indicates truncation

  • Extension prevention: appending after is_final=1 fails AEAD verification

  • Streaming writes: encryptors buffer the last segment until the stream closes, then encrypt it with is_final=1

Per-segment random nonces (Section 5.8.4) enable selective editing: individual segments can be re-encrypted without affecting LOCK blocks or other segments. This design trades a small storage overhead (Nn octets per segment) for flexibility in payload modification.

F.3. Fixed HKDF Salt

SafeDerive (Section 5.4.1) uses a fixed string ("SAFE-v1") as the HKDF salt in its Extract step. The same string appears in the Encode-framed Input Keying Material (IKM), making its presence in the salt redundant but harmless. This design is intentional: the fixed salt binds every Extract call to the protocol version at the HMAC key position, while security relies on the entropy of the IKM, not the salt. All security-critical IKMs (CEK, step secrets, hedged random values) have sufficient min-entropy for this to hold. Krawczyk's analysis of HKDF shows that Extract with a fixed salt is a good randomness extractor when the IKM has high min-entropy. For the XOF single-stage variant, there is no separate salt; the version string appears only within the Encode frame.

Appendix G. Test Vectors

This appendix is informative. It collects known-answer tests and derivation vectors for SAFE. All values are hex unless noted.

G.1. Passphrase Example

This subsection is a complete known-answer test for a passphrase-based SAFE encoding using the default SEAL-editable Profile (aes-256-gcm, segment size 65536, hkdf-sha-256, random nonces, snapshot on) with Data-Encoding armored.

## Inputs
Passphrase:    "correct horse battery staple" (UTF-8, 28 octets)
Salt:          01010101010101010101010101010101
CEK:           aaaaaaaa...aa (32 octets of 0xAA)
lock_nonce:    020202020202020202020202
payload_salt:  0404...0404 (32 octets of 0x04)
Plaintext:     "Hello, SAFE!" (12 octets)
seg0 nonce:    030303030303030303030303 (stored random nonce)

## encryption_parameters (5 elements, KEK-bound)
encryption_parameters = [
  aead_id:          0002 (AEAD_AES_256_GCM)
  segment_size_be:  00010000 (= I2OSP(65536, 4))
  kdf_id:           0001 (HKDF-SHA-256)
  snap_id:          0001 (snapshot on)
  epoch_length_u8:  10 (r = 16, SEAL-editable Profile)
]
payload_info additionally carries, before the salt:
  nonce_mode:       00 (random)

## LOCK Key Derivation
step_binding:
  00047061737300086172676f6e326964
  00100101010101010101010101010101
  0101
    Encode("pass", "argon2id", salt)
step_secret:
  7d3491ac8af1b54526792869b7257f5d
  bf7cc3c20929417bb193e396c51d7965
    DerivePasswordStepSecret(argon2id)
derived_kek:
  edc057d21862641949fbdc1607f8b2b2
  d8e85eca45b82d9fc100c53e94623114
    SafeDerive("lock-kek", [step_secret],
    [...encryption_parameters, step_binding], Nk)

## Encrypted-CEK
aad = "" (empty)
Encrypted-CEK:
  02020202020202020202020206265af6
  e8060e7a7522dafb2d35697599a8f935
  30178851ff8381916e05cd8a09202873
  4f1d066269d6a7f311b86bb7
    lock_nonce || AEAD.Seal(KEK, lock_nonce, CEK)

## Payload Schedule
payload_info = [aead_id, segment_size_be, kdf_id, snap_id,
  nonce_mode, epoch_length_u8, salt]  (7 elements)
payload_salt:
  04040404040404040404040404040404
  04040404040404040404040404040404
commitment:
  c09e0f51871d431bc5624676212acf76
  c8239ea5c8e54cd6b225d271122c4e8e
    SealDerive("commit", CEK, [...payload_info, G], 32)
payload_key:
  17f41951e5551ebf40eb781455df28f4
  3ec6f249f1201892d3479f5e1ca500c4
    SealDerive("payload_key", CEK, payload_info, 32)
snap_key:
  b2aaae9c93648106d48c98063f310dc6
  b227aef1aeee6e1a36515ea359dcd2e3
    SealDerive("acc_key", CEK, payload_info, 32)

## Segment 0 (is_final=1)
nonce_0:
  030303030303030303030303
data_aad_0:
  00095345414c2d444154410008000000
  0000000000000101
    Encode("SEAL-DATA", uint64(0), uint8(1))
segment_key_0:
  5d437730ddfc2e313028ea4ad3b346bf
  fac22e0684a6721c388e6de66e9a1ca8
    epoch_key(0) = SealDerive("epoch_key",
    payload_key, [uint64(0)], 32)
ciphertext+tag_0:
  638bdea85af8bee96102a9d4aad21177
  de9020008d812a702619700d
tag_0:
  aad21177de9020008d812a702619700d
encrypted_block_0:
  030303030303030303030303638bdea8
  5af8bee96102a9d4aad21177de902000
  8d812a702619700d

## Snapshot (snap_id 0x0001)
contrib_0:
  ebd45d826cff550730838787dc361e9e
  553f6bb006c8486aea1be12ffefb32aa
accumulator:
  ebd45d826cff550730838787dc361e9e
  553f6bb006c8486aea1be12ffefb32aa
    = contrib_0 (single segment)
snapshot_tag:
  57a67865e7ee2d152dd2cf427381a85c
  37a00a0863306fc93255c702663185ce
    SnapTag: SealDerive("snapshot_tag", snap_key,
    [uint64(1), accumulator], 32)
snapshot (Na=64) = wrapped_acc || snapshot_tag:
snapshot:
  46db8d79155f6c25857b09f4b2230fc5
  8328044dd548e66442fe67b893da7e76
  57a67865e7ee2d152dd2cf427381a85c
  37a00a0863306fc93255c702663185ce
    wrapped_acc = accumulator XOR
      snapmask(n_seg, snapshot_tag)

## Complete DATA object (168 octets)
DATA = salt(32) || commitment(32) || snapshot(64)
       || [ nonce(12) || ct+tag per segment ]
       = 168 octets, 224 Base64 chars

Readable format: ~~~~ -----BEGIN SAFE CONFIG----- Lock-Encoding: readable Data-Encoding: armored -----END SAFE CONFIG----- -----BEGIN SAFE LOCK----- Step: pass(kdf=argon2id, salt=AQEBAQEBAQEBAQEBAQEBAQ==) Encrypted-CEK: AgICAgICAgICAgICBiZa9ugGDnp1Itr7LTVpdZmo+TUwF4hR/4OBkW4FzYoJIChz Tx0GYmnWp/MRuGu3 -----END SAFE LOCK----- -----BEGIN SAFE DATA----- BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBATAng9Rhx1DG8ViRnYhKs92 yCOepcjlTNayJdJxEixOjkbbjXkVX2wlhXsJ9LIjD8WDKARN1UjmZEL+Z7iT2n52 V6Z4ZefuLRUt0s9Cc4GoXDegCghjMG/JMlXHAmYxhc4DAwMDAwMDAwMDAwNji96o Wvi+6WECqdSq0hF33pAgAI2BKnAmGXAN -----END SAFE DATA----- ~~~~

Same object, armored LOCK (default Lock-Encoding): ~~~~ -----BEGIN SAFE CONFIG----- Data-Encoding: armored -----END SAFE CONFIG----- -----BEGIN SAFE LOCK----- ACIABHBhc3MACGFyZ29uMmlkABABAQEBAQEBAQEBAQEBAQEBADwCAgICAgICAgIC AgIGJlr26AYOenUi2vstNWl1maj5NTAXiFH/g4GRbgXNigkgKHNPHQZiadan8xG4 a7c= -----END SAFE LOCK----- -----BEGIN SAFE DATA----- BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBATAng9Rhx1DG8ViRnYhKs92 yCOepcjlTNayJdJxEixOjkbbjXkVX2wlhXsJ9LIjD8WDKARN1UjmZEL+Z7iT2n52 V6Z4ZefuLRUt0s9Cc4GoXDegCghjMG/JMlXHAmYxhc4DAwMDAwMDAwMDAwNji96o Wvi+6WECqdSq0hF33pAgAI2BKnAmGXAN -----END SAFE DATA----- ~~~~

G.2. X25519 Test Vector

This appendix is informative.

A single X25519 recipient using DHKEM(X25519, HKDF-SHA256), with the recipient keys from [RFC9180] Appendix A.7.1 and the default SEAL-editable Profile. All values are hex unless noted.

## Inputs
Recipient skR:
  4612c550263fc8ad58375df3f557aac5
  31d26850903e55a9f23f21d8534e8ac8
Recipient pkR:
  3948cfe0ad1ddb695d780e59077195da
  6c56506b027329794ab02bca80815c4d
Ephemeral skE:
  52c4a758a802cd8b936eceea31443279
  8d5baf2d7e9235dc084ab1b9cfa2f736
Ephemeral pkE (= enc):
  37fda3567bdbd628e88668c3c8d7e97d
  1d1253b6d4ea6d44c150f741f1bf4431
CEK:           aaaaaaaa...aa (32 octets of 0xAA)
lock_nonce:    020202020202020202020202
payload_salt:  0404...0404 (32 octets of 0x04)
Plaintext:     "Hello, SAFE!" (12 octets)
seg0 nonce:    030303030303030303030303 (stored random nonce)

## encryption_parameters (5 elements, KEK-bound)
encryption_parameters = [
  aead_id:          0002 (AEAD_AES_256_GCM)
  segment_size_be:  00010000 (= I2OSP(65536, 4))
  kdf_id:           0001 (HKDF-SHA-256)
  snap_id:          0001 (snapshot on)
  epoch_length_u8:  10 (r = 16, SEAL-editable Profile)
]
payload_info additionally carries, before the salt:
  nonce_mode:       00 (random)

## DHKEM(X25519, HKDF-SHA256)
dh = DH(skE, pkR):
  b3b5c19eab3f088ac18f23f774ff6414
  ba4fde45404d10085efc3e4dc9c72e35
kem_context = enc || pkR:
  37fda3567bdbd628e88668c3c8d7e97d
  1d1253b6d4ea6d44c150f741f1bf4431
  3948cfe0ad1ddb695d780e59077195da
  6c56506b027329794ab02bca80815c4d
shared_secret:
  fe0e18c9f024ce43799ae393c7e8fe8f
  ce9d218875e8227b0187c04e7d2ea1fc

## SAFE Step Secret (base mode)
key_id (id):
  98cdd10b776ac15ed78f5520bed9f3e6
  ffdf682fe3ecb68163b4f1dd8b1dfefa
    SafeDerive("SAFE-SPKI-v1", [SPKI(pkR)], [""], 32)
step_binding:
  000468706b6500067832353531390020
  37fda3567bdbd628e88668c3c8d7e97d
  1d1253b6d4ea6d44c150f741f1bf4431
  002098cdd10b776ac15ed78f5520bed9
  f3e6ffdf682fe3ecb68163b4f1dd8b1d
  fefa
    Encode("hpke", "x25519", enc, id)
step_secret:
  42a4a3f299e1a71a97b04a3d9a7e9ae6
  7cd1b8ea3dec017e26fa1e369ee6f85b
    HPKE Base export over the shared_secret

## LOCK Key Derivation
derived_kek:
  33256ae6986d734cab5564a7f69af526
  b7f2a233b6524fb07575a9ab2fc0e01e
    SafeDerive("lock-kek", [step_secret],
    [...encryption_parameters, step_binding], Nk)

## Encrypted-CEK
Encrypted-CEK:
  02020202020202020202020275e7823b
  d597c54b038f701123cd1e766936d5ea
  40a86d5698d492570403b93f90617043
  74b370c7bee30f74fb194cc5

## Payload Schedule
payload_info = [aead_id, segment_size_be, kdf_id, snap_id,
  nonce_mode, epoch_length_u8, salt]  (7 elements)
payload_salt:
  04040404040404040404040404040404
  04040404040404040404040404040404
commitment:
  c09e0f51871d431bc5624676212acf76
  c8239ea5c8e54cd6b225d271122c4e8e
    SealDerive("commit", CEK, [...payload_info, G], 32)
payload_key:
  17f41951e5551ebf40eb781455df28f4
  3ec6f249f1201892d3479f5e1ca500c4
    SealDerive("payload_key", CEK, payload_info, 32)
snap_key:
  b2aaae9c93648106d48c98063f310dc6
  b227aef1aeee6e1a36515ea359dcd2e3
    SealDerive("acc_key", CEK, payload_info, 32)

## Segment 0 (is_final=1)
nonce_0:
  030303030303030303030303
data_aad_0:
  00095345414c2d444154410008000000
  0000000000000101
    Encode("SEAL-DATA", uint64(0), uint8(1))
segment_key_0:
  5d437730ddfc2e313028ea4ad3b346bf
  fac22e0684a6721c388e6de66e9a1ca8
    epoch_key(0) = SealDerive("epoch_key",
    payload_key, [uint64(0)], 32)
ciphertext+tag_0:
  638bdea85af8bee96102a9d4aad21177
  de9020008d812a702619700d
tag_0:
  aad21177de9020008d812a702619700d
encrypted_block_0:
  030303030303030303030303638bdea8
  5af8bee96102a9d4aad21177de902000
  8d812a702619700d

## Snapshot (snap_id 0x0001)
contrib_0:
  ebd45d826cff550730838787dc361e9e
  553f6bb006c8486aea1be12ffefb32aa
accumulator:
  ebd45d826cff550730838787dc361e9e
  553f6bb006c8486aea1be12ffefb32aa
    = contrib_0 (single segment)
snapshot_tag:
  57a67865e7ee2d152dd2cf427381a85c
  37a00a0863306fc93255c702663185ce
    SnapTag: SealDerive("snapshot_tag", snap_key,
    [uint64(1), accumulator], 32)
snapshot (Na=64) = wrapped_acc || snapshot_tag:
snapshot:
  46db8d79155f6c25857b09f4b2230fc5
  8328044dd548e66442fe67b893da7e76
  57a67865e7ee2d152dd2cf427381a85c
  37a00a0863306fc93255c702663185ce
    wrapped_acc = accumulator XOR
      snapmask(n_seg, snapshot_tag)

## Complete DATA object (168 octets)
DATA = salt(32) || commitment(32) || snapshot(64)
       || [ nonce(12) || ct+tag per segment ]
       = 168 octets, 224 Base64 chars

G.3. Auth Mode Test Vector

This appendix is informative.

Auth-mode HPKE with a single X25519 recipient, using the same recipient keys as the X25519 vector and the default SEAL-editable Profile. All values are hex unless noted.

## Inputs
Recipient skR:
  4612c550263fc8ad58375df3f557aac5
  31d26850903e55a9f23f21d8534e8ac8
Recipient pkR:
  3948cfe0ad1ddb695d780e59077195da
  6c56506b027329794ab02bca80815c4d
Sender skS:
  6b7298af684f45181f80ac5cb3d9a371
  3abb62cbecd21db5dba0eb2a8bfb3a05
Sender pkS:
  ccc340219b8098b48749f1c36e2c336f
  aefb87f9cbe3463e59e3b8ec18c44c49
Ephemeral skE:
  52c4a758a802cd8b936eceea31443279
  8d5baf2d7e9235dc084ab1b9cfa2f736
Ephemeral pkE (= enc):
  37fda3567bdbd628e88668c3c8d7e97d
  1d1253b6d4ea6d44c150f741f1bf4431
CEK:           aaaaaaaa...aa (32 octets of 0xAA)
lock_nonce:    020202020202020202020202

## encryption_parameters (5 elements, KEK-bound)
encryption_parameters = [
  aead_id:          0002 (AEAD_AES_256_GCM)
  segment_size_be:  00010000 (= I2OSP(65536, 4))
  kdf_id:           0001 (HKDF-SHA-256)
  snap_id:          0001 (snapshot on)
  epoch_length_u8:  10 (r = 16, SEAL-editable Profile)
]
payload_info additionally carries, before the salt:
  nonce_mode:       00 (random)

## DHKEM Auth Mode (X25519)
shared_secret:
  8c1cd5a4e40708af36bf6446d65f62ed
  7a7b7f1286d966d9bc96b96808021ba8

## SAFE Step Secret (Auth mode)
id:
  98cdd10b776ac15ed78f5520bed9f3e6
  ffdf682fe3ecb68163b4f1dd8b1dfefa
    SafeDerive("SAFE-SPKI-v1", [SPKI(pkR)], [""], 32)
sid:
  d9b9d59d0f10a55a8365b2f440dbf787
  f280e0f400427beaaf17de1f8662fcdc
    SafeDerive("SAFE-SPKI-v1", [SPKI(pkS)], [""], 32)
step_binding:
  000468706b6500067832353531390020
  37fda3567bdbd628e88668c3c8d7e97d
  1d1253b6d4ea6d44c150f741f1bf4431
  002098cdd10b776ac15ed78f5520bed9
  f3e6ffdf682fe3ecb68163b4f1dd8b1d
  fefa0004617574680020d9b9d59d0f10
  a55a8365b2f440dbf787f280e0f40042
  7beaaf17de1f8662fcdc
    Encode("hpke", "x25519", enc, id, "auth", sid)
step_secret:
  24c50a152128baaa3f77c255b0e5b895
  2cece2e7680b21bd7dc24aa1807e9374
    HPKE Auth export over the shared_secret

## LOCK Key Derivation
derived_kek:
  c69b80d05f8e61938af6935b6e91ad90
  3a675c94f927d17cf8e2e35961c25522

## Encrypted-CEK
Encrypted-CEK:
  0202020202020202020202029f79d2aa
  42b0b4ae4f055655abaf041fd8d697ca
  ec9d493e86bbd25c16c2e3bc1654050d
  923d573048785e94cebe6d91

## Payload Schedule
payload_info = [aead_id, segment_size_be, kdf_id, snap_id,
  nonce_mode, epoch_length_u8, salt]  (7 elements)
payload_salt:
  04040404040404040404040404040404
  04040404040404040404040404040404
commitment:
  c09e0f51871d431bc5624676212acf76
  c8239ea5c8e54cd6b225d271122c4e8e
    SealDerive("commit", CEK, [...payload_info, G], 32)
payload_key:
  17f41951e5551ebf40eb781455df28f4
  3ec6f249f1201892d3479f5e1ca500c4
    SealDerive("payload_key", CEK, payload_info, 32)
snap_key:
  b2aaae9c93648106d48c98063f310dc6
  b227aef1aeee6e1a36515ea359dcd2e3
    SealDerive("acc_key", CEK, payload_info, 32)

## Segment 0 (is_final=1)
nonce_0:
  030303030303030303030303
data_aad_0:
  00095345414c2d444154410008000000
  0000000000000101
    Encode("SEAL-DATA", uint64(0), uint8(1))
segment_key_0:
  5d437730ddfc2e313028ea4ad3b346bf
  fac22e0684a6721c388e6de66e9a1ca8
    epoch_key(0) = SealDerive("epoch_key",
    payload_key, [uint64(0)], 32)
ciphertext+tag_0:
  638bdea85af8bee96102a9d4aad21177
  de9020008d812a702619700d
tag_0:
  aad21177de9020008d812a702619700d
encrypted_block_0:
  030303030303030303030303638bdea8
  5af8bee96102a9d4aad21177de902000
  8d812a702619700d

## Snapshot (snap_id 0x0001)
contrib_0:
  ebd45d826cff550730838787dc361e9e
  553f6bb006c8486aea1be12ffefb32aa
accumulator:
  ebd45d826cff550730838787dc361e9e
  553f6bb006c8486aea1be12ffefb32aa
    = contrib_0 (single segment)
snapshot_tag:
  57a67865e7ee2d152dd2cf427381a85c
  37a00a0863306fc93255c702663185ce
    SnapTag: SealDerive("snapshot_tag", snap_key,
    [uint64(1), accumulator], 32)
snapshot (Na=64) = wrapped_acc || snapshot_tag:
snapshot:
  46db8d79155f6c25857b09f4b2230fc5
  8328044dd548e66442fe67b893da7e76
  57a67865e7ee2d152dd2cf427381a85c
  37a00a0863306fc93255c702663185ce
    wrapped_acc = accumulator XOR
      snapmask(n_seg, snapshot_tag)

## Complete DATA object (168 octets)
DATA = salt(32) || commitment(32) || snapshot(64)
       || [ nonce(12) || ct+tag per segment ]
       = 168 octets, 224 Base64 chars

G.4. Multi-Segment Test Vector

This appendix is informative.

A two-segment passphrase-based encoding under the default SEAL-editable Profile, exercising the shared epoch key (at epoch length 16, segments 0 and 1 both derive epoch_key(0)), the segment AAD finality bit, and the two-contribution snapshot fold. All values are hex unless noted.

## Inputs
Passphrase:   "correct horse battery staple" (28 octets)
Salt:         0101...0101 (32 octets of 0x01)
CEK:          aaaaaaaa...aa (32 octets of 0xAA)
lock_nonce:   020202020202020202020202
payload_salt: 0404...0404 (32 octets of 0x04)
Segment 0:    "Block zero data!" (16 octets)
Segment 1:    "Final block." (12 octets, final)
seg0 nonce:   030303030303030303030303
seg1 nonce:   050505050505050505050505

## encryption_parameters (5 elements, KEK-bound)
encryption_parameters = [
  aead_id:          0002 (AEAD_AES_256_GCM)
  segment_size_be:  00010000 (= I2OSP(65536, 4))
  kdf_id:           0001 (HKDF-SHA-256)
  snap_id:          0001 (snapshot on)
  epoch_length_u8:  10 (r = 16, SEAL-editable Profile)
]
payload_info additionally carries, before the salt:
  nonce_mode:       00 (random)

## LOCK Key Derivation
step_binding:
  00047061737300086172676f6e326964
  00200101010101010101010101010101
  01010101010101010101010101010101
  0101
step_secret:
  2bab4c606fb5c84123e48f0bb5eeb2a1
  39a6dd996c6cff2efad2ad78a12143e0
derived_kek:
  afafa8b7c85d5ffb1cb713c7844afed1
  ad2821b25acabd26b96a724265c2083b

## Encrypted-CEK
Encrypted-CEK:
  020202020202020202020202576e8f8a
  46f8ab09684a3dfcd7ba493d328b875a
  51b82e2f5bea35fe6e7582c5494e69f2
  d64a154d317f6cb3f5448b9e

## Payload Schedule
payload_info = [aead_id, segment_size_be, kdf_id, snap_id,
  nonce_mode, epoch_length_u8, salt]  (7 elements)
payload_salt:
  04040404040404040404040404040404
  04040404040404040404040404040404
commitment:
  c09e0f51871d431bc5624676212acf76
  c8239ea5c8e54cd6b225d271122c4e8e
    SealDerive("commit", CEK, [...payload_info, G], 32)
payload_key:
  17f41951e5551ebf40eb781455df28f4
  3ec6f249f1201892d3479f5e1ca500c4
    SealDerive("payload_key", CEK, payload_info, 32)
snap_key:
  b2aaae9c93648106d48c98063f310dc6
  b227aef1aeee6e1a36515ea359dcd2e3
    SealDerive("acc_key", CEK, payload_info, 32)

## Segment 0 (is_final=0)
nonce_0:
  030303030303030303030303
data_aad_0:
  00095345414c2d444154410008000000
  0000000000000100
    Encode("SEAL-DATA", uint64(0), uint8(0))
segment_key_0:
  5d437730ddfc2e313028ea4ad3b346bf
  fac22e0684a6721c388e6de66e9a1ca8
    epoch_key(0) = SealDerive("epoch_key",
    payload_key, [uint64(0)], 32)
ciphertext+tag_0:
  6982dda75ef4e4df522bcc91869718a6
  c5f91e976ca47a9b181e18ee7b5b9b6f
tag_0:
  c5f91e976ca47a9b181e18ee7b5b9b6f
encrypted_block_0:
  0303030303030303030303036982dda7
  5ef4e4df522bcc91869718a6c5f91e97
  6ca47a9b181e18ee7b5b9b6f

## Segment 1 (is_final=1)
nonce_1:
  050505050505050505050505
data_aad_1:
  00095345414c2d444154410008000000
  0000000001000101
    Encode("SEAL-DATA", uint64(1), uint8(1))
segment_key_1:
  5d437730ddfc2e313028ea4ad3b346bf
  fac22e0684a6721c388e6de66e9a1ca8
    epoch_key(1 >> 16) = epoch_key(0),
    the same key as segment 0
ciphertext+tag_1:
  67ff80f4a0b9df1426d12535896e5208
  0d8bbe80233148f1afb797fc
tag_1:
  896e52080d8bbe80233148f1afb797fc
encrypted_block_1:
  05050505050505050505050567ff80f4
  a0b9df1426d12535896e52080d8bbe80
  233148f1afb797fc

## Snapshot (snap_id 0x0001)
contrib_0:
  4a0349a2a5b2487ec87b536a2149d0c9
  19c9c8762cb664fdd242273a24223818
contrib_1:
  df766e199abe6c7b8c6b3589b12cea43
  07aa7ec91073c4c68c87862e9cac4c13
accumulator:
  957527bb3f0c2405441066e390653a8a
  1e63b6bf3cc5a03b5ec5a114b88e740b
    = XOR of all contrib_i
snapshot_tag:
  53972ea6b3021a4c5131bc9efe707e15
  d1eeee0f336ec6c4909d27d1d304d87e
    SnapTag: SealDerive("snapshot_tag", snap_key,
    [uint64(2), accumulator], 32)
snapshot (Na=64) = wrapped_acc || snapshot_tag:
snapshot:
  34f84e6b3b3debb10ea7c46cd21f4cc1
  392d96050ad7733829b1bc909918edbf
  53972ea6b3021a4c5131bc9efe707e15
  d1eeee0f336ec6c4909d27d1d304d87e
    wrapped_acc = accumulator XOR
      snapmask(n_seg, snapshot_tag)

## Complete DATA object (212 octets)
DATA = salt(32) || commitment(32) || snapshot(64)
       || [ nonce(12) || ct+tag per segment ]
       = 212 octets, 284 Base64 chars

G.5. SafeDerive Test Vectors

This appendix is informative. These vectors validate the SafeDerive function in isolation, independent of the SAFE protocol. Inputs are deliberately short for readability. See Section 5.4.1 for the definition.

Common inputs for all vectors:

label:  "SAFE-TEST" (9 octets, hex: 534146452d54455354)
ikm:    0a0b0c0d0e0f (6 octets)
info:   "" (0 octets)

G.5.1. KDF=hkdf-sha-256, L=32

Extract salt = "SAFE-v1":
  534146452d7631

Extract ikm = Encode("SAFE-v1",
    label, ...ikm):
  0007534146452d76310009534146452d5445535400060a0b0c0d0e0f

prk = Extract(salt, ikm):
  983d59830192955caf33fff4056ed415e2cd1cef7fe3072e075cf90903c97146

Expand info = Encode("SAFE-v1",
    label, ...info,
    I2OSP(32, 2)):
  0007534146452d76310009534146452d54455354000000020020

output = Expand(prk, info, 32):
  d7413c70bb7bde999f5e543c0796d63a0af6839ebbe5203cc526776b978ba147

G.5.2. KDF=hkdf-sha-256, L=16

output = SafeDerive(label, ikm, info, 16):
  e190628e91995808047c49a7269b9d3b

G.5.3. KDF=turboshake256, L=32

The single-stage form frames ikm and info each as a single element (Section 4.3 of [I-D.sullivan-cfrg-raae]):

Derive(Encode("SAFE-v1", label,
              Encode(...ikm),
              Encode(...info),
              I2OSP(32, 2)), 32):
  input:  0007534146452d76310009534146452d54455354000800060a0b0c0d0e
          0f000000020020
  output: 03e6808974a8943c0055534d644ab7a6385e22cb45e68c7489ae35f7da
          b55eff

G.5.4. KDF=turboshake256, L=16

output = SafeDerive(label, ikm, info, 16):
  a25ddbeaa80e5196220df7ff048c24c9

G.5.5. Combiner input coverage

These vectors exercise the combiner's handling of empty and multi-element ikm and info lists, and confirm the ikm/info partition is injective: splitting the elements as (A, B) and (C) versus (A) and (B, C) yields different outputs even though concatenating all elements gives the same octets. Each uses label "SAFE-TEST" with A = 11, B = 22, C = 33, and L = 32. Inputs are shown as ikm and info element lists.

ikm = [], info = [0a0b0c0d0e0f]
hkdf-sha-256:   6c08fb42193087c785c1b77679af1adc
                ee37834ecb2ebd1ca0fb385ef85c7a48
turboshake256:  efc1b6d7841cea2980ad2c03ce64773d
                139cebb94cbff0c637eaa1ce9e870590

ikm = [0a0b, 0c0d0e0f], info = []
hkdf-sha-256:   dfd4b424aa995f452a298be4de7b6c4b
                1157e5729e63612f2c19de875ed1caa3
turboshake256:  1c46141340f6411c62059f64602bf086
                908527ec272a26ff40ed71b0c3bec8eb

ikm = [0a0b0c0d0e0f], info = [1122, 3344]
hkdf-sha-256:   5d94a2cc1642a84fc19f03e556efc444
                492d0102fab432bfa0d34baca0ec68c3
turboshake256:  e5c3a82ede7d8bfda09bc790d8a927dd
                aefae4f68823146d512a76afc086de7d

ikm = [A, B], info = [C]      (boundary)
hkdf-sha-256:   cf2c3e205da8633133566a28fb7291c4
                d42ae36187c82351620d6263931f1b71
turboshake256:  742d5e666a872a2fdfa487a1c9d12767
                2572612731745007c163acd85ed92e72

ikm = [A], info = [B, C]      (boundary)
hkdf-sha-256:   6871955ff57be95706939812c6a4b607
                698a319bc7ee6408d521cc30e42dc0a3
turboshake256:  22f8488d564a60a48e6290ddb68824e0
                2618843998748d2ed233f40ad16fe583

The two boundary outputs differ in both KDF classes: the two-stage KDF separates ikm and info across Extract and Expand, and the single-stage KDF frames ikm and info as distinct elements (Section 4.3 of [I-D.sullivan-cfrg-raae]).

G.6. LOCK Key Derivation Properties

This appendix is informative. These vectors demonstrate three properties of LOCK Key Derivation (Section 5.7.1): step order is bound, display-only descriptor fields are excluded from the binding, and every bound field changes the KEK. They exercise the KEK derivation directly and do not depend on how a step produces its secret, so the step secrets below are fixed illustrative values rather than the output of any particular step type.

Common inputs:

encryption_parameters (5 elements: AES-256-GCM, segment size 65536,
  hkdf-sha-256, snapshot 0x0001, epoch 0x10):
    aead_id          0002
    segment_size_be  00010000
    kdf_id           0001
    snap_id          0001
    epoch_length_u8  10

pass step bindings, Encode("pass", "argon2id", salt); the display-only
  label is not a binding field:
    bA (saltA = a1 repeated 16 times):
      00047061737300086172676f6e3269640010
      a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1
    bB (saltB = b2 repeated 16 times):
      00047061737300086172676f6e3269640010
      b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2

step secrets (fixed illustrative values):
    ssA = 51 repeated 32 times
    ssB = 62 repeated 32 times

The KEK is SafeDerive("lock-kek", step_secrets, [...encryption_parameters, step_binding_1, ..., step_binding_N], 32) per Section 5.7.1.

G.6.1. Step order is bound

Reordering the steps changes the KEK. Both derivations use the same two (binding, secret) pairs in opposite order:

KEK([bA, bB], [ssA, ssB]) =
  d6d2eb11b2c9576b8fe62913764cde148cd31779dd085a371419af2d3424e441
KEK([bB, bA], [ssB, ssA]) =
  30580bc1058200876045afe967e6344ce7e7bd235494cadc6cde8d48117996e4

G.6.2. Display-only fields do not affect the KEK

The pass step binding is Encode("pass", kdf, salt); a descriptor's display-only label is not an input. Two descriptors that differ only in their label produce the identical binding bA and therefore the identical single-step KEK:

KEK([bA], [ssA]) =
  67babd8d18ffac2c506b0804843cb8870db8ce3ba5a23ca24502f72dd7cdff40

G.6.3. Every bound field changes the KEK

Changing a bound field changes the KEK even when the step secret is unchanged. Both derivations use secret ssA; only the bound salt differs (bA carries saltA, bB carries saltB):

KEK([bA], [ssA]) =
  67babd8d18ffac2c506b0804843cb8870db8ce3ba5a23ca24502f72dd7cdff40
KEK([bB], [ssA]) =
  c123cf374061810c7db73d58431146ffee6588f3b74391d55bd399097cbed498

Appendix H. Rejection Test Cases

This appendix is informative. It collects malformed inputs that a conforming Decryptor MUST reject, each with the normative requirement that mandates rejection. A conformance suite SHOULD include a negative case for each row.

Table 22: Inputs a conforming Decryptor MUST reject
Malformed input Rejection required by
Base64 with a character outside the standard alphabet, or padding in an invalid position Section 4
An integer field that is not canonical UINT (leading zero, sign, or whitespace) Section 4
A CONFIG block with a duplicate field name Section 6.1
A CONFIG field name not in the SAFE Config Options registry Section 6.1
A Profile value other than one of the six Section 5.8.1 registers Section 6.1
The SEAL-compact Profile combined with a non-MRAE AEAD Section 6.1
A CONFIG block containing a non-ASCII octet Section 6.1
A LOCK with multiple Encrypted-CEK lines or an unknown field name Section 6.2.1
A step descriptor with a parameter name not defined for its step type Section 6.2
A step descriptor whose parameters are out of the specified order Section 6.2.1.1
A step descriptor with a duplicate parameter name Section 6.2.1.1
A step descriptor missing a parameter its step type marks as required (pass without salt, hpke without kemct) Section 6.2.1.1
An Encrypted-CEK not decoding to Nn + 32 + 16 octets Section 6.2
A payload whose derived commitment does not match Section 5.8.2

Appendix I. Defining New Step Types

This appendix is informative.

New step types can be defined and registered to extend SAFE with additional authentication mechanisms. This section illustrates the process using three hypothetical steps: two Privacy Pass Key Derivation Function (PPKDF) steps for token-gated key derivation (Appendix I.1), and a WebAuthn PRF step for hardware token authentication.

I.1. Example: Privacy Pass Steps

Privacy Pass [RFC9578] type 0x0001 tokens use a Verifiable Oblivious Pseudorandom Function (VOPRF) [RFC9497]. The client constructs a TokenInput containing a nonce, blinds it, and sends the blinded element to the issuer. The issuer evaluates its PRF on the blinded element and returns the result. The client unblinds to obtain the authenticator, which is a deterministic function of the TokenInput and the issuer's secret key.

In normal issuance the nonce is random, making each token unique. PPKDF sets the nonce to a deterministic value derived from the SAFE context, so the authenticator is reproducible and serves as step key material. The issuer's behavior is unchanged and cannot distinguish a PPKDF request from normal token issuance.

Two step types use this mechanism:

  • ppkdf: token-gated key derivation with application-supplied context.

  • ppkdf-pass: token-gated key derivation with password-derived context for online throttling.

I.1.1. Shared Parameters

issuer (required):

Server name (host or host:port) of the Privacy Pass token issuer. The issuer holds the VOPRF key pair; its public key pkI must be known to the client.

salt (required):

Base64-encoded salt; must decode to exactly 32 octets. Generated at encryption time using SafeRandom.

The step_binding uses the Encode form (Section 5.6):

  • ppkdf: Encode("ppkdf", issuer, salt)

  • ppkdf-pass: Encode("ppkdf-pass", issuer, kdf, salt)

where issuer and kdf are UTF-8 strings and salt is the raw decoded 32 octets.

I.1.2. ppkdf Step

The ppkdf step provides token-gated key derivation with application-supplied context.

Token form:

ppkdf(issuer=tokens.example.com,salt=<Base64>)

Grammar:

ppkdf-step    = "ppkdf(" ppkdf-params ")"
ppkdf-params  = "issuer=" pp-host "," "salt=" salt
pp-host       = host [ ":" port ]
host          = 1*( ALPHA / DIGIT / "-" / "." )
port          = 1*DIGIT
salt          = 1*BASE64CHAR    ; 44 chars = 32 octets

Derivation:

Decode salt to salt_bytes (32 octets). Compute a deterministic nonce for the TokenInput:

nonce = SafeDerive("SAFE-PPKDF-NONCE",
    "", [...encryption_parameters,
         step_binding, salt_bytes], 32)

where step_binding is the Encode-based binding form defined in Section 5.6. Construct a type 0x0001 TokenInput ([RFC9578], Section 5.1) with nonce as above. Set challenge_digest to:

SafeDerive("SAFE-PPKDF-CHALLENGE", TokenChallenge,
           encryption_parameters, 32)

using a TokenChallenge with issuer_name = issuer and empty redemption_context and origin_info.

Blind the TokenInput and send in a standard type 0x0001 TokenRequest. The issuer evaluates the VOPRF and returns a TokenResponse. Verify the VOPRF proof and unblind to obtain the authenticator (32 octets). Set step_secret = authenticator.

I.1.3. ppkdf-pass Step

The ppkdf-pass step adds password-derived input. Each password guess requires a VOPRF evaluation from the issuer.

Token form:

ppkdf-pass(issuer=tokens.example.com,kdf=argon2id,salt=<Base64>)

Grammar:

ppkdf-pass-step  = "ppkdf-pass(" ppkdf-pass-params ")"
ppkdf-pass-params = "issuer=" pp-host ","
                    "kdf=" kdf-name "," "salt=" salt
kdf-name          = "argon2id" / "pbkdf2"

The kdf parameter selects the password KDF using the same algorithms and default parameters as the pass() step (Section 5.6.2).

Derivation:

Decode salt to salt_bytes (32 octets). Derive pw32 from the user's password using the KDF indicated by the kdf parameter, with salt_bytes as salt input and the default parameters defined in Section 5.6.2. Compute the deterministic nonce:

nonce = SafeDerive("SAFE-PPKDF-PASS",
    pw32, [...encryption_parameters,
           step_binding], 32)

Execute the PPKDF protocol as in the ppkdf step using this nonce. Set step_secret = authenticator.

I.1.4. IANA Registry Entries

Registration of ppkdf and ppkdf-pass is deferred to a separate document; this appendix is informative. A registration for these steps would include:

Table 23
Step Name Parameters Inputs Secret Ref
ppkdf issuer=X, salt=X PPKDF token 32 octets (this doc)
ppkdf-pass issuer=X, kdf=X, salt=X PPKDF token, password 32 octets (this doc)

I.1.5. Security Considerations for Privacy Pass Steps

For ppkdf:

The issuer sees only the blinded_element. It cannot learn context, step_secret, or anything about the SAFE file. The VOPRF proof lets the client detect the wrong issuer key. Compromise of skI enables offline computation of step_secret for any context, breaking the online-gating property.

For ppkdf-pass:

Each password guess requires a VOPRF evaluation; issuer rate limits control guessing frequency. The issuer never sees the password-derived context. Compromise of skI still requires inverting the memory-hard KDF to recover the password.

Example LOCK block:

-----BEGIN SAFE CONFIG-----
Lock-Encoding: readable
-----END SAFE CONFIG-----
-----BEGIN SAFE LOCK-----
Step: ppkdf(issuer=tokens.example.com,salt=<Base64>)
Encrypted-CEK:
  Base64-encoded encrypted CEK
-----END SAFE LOCK-----

I.2. Example: WebAuthn PRF Step

A WebAuthn-based step would allow hardware token authentication using the PRF extension defined in Web Authentication Level 3 [W3C.webauthn-3]. Unlike WebAuthn assertions (signatures), the PRF extension provides deterministic output suitable for SAFE's step model.

I.2.1. Step Definition

Step name:

webauthn-prf

The webauthn-prf step descriptor has three forms:

webauthn-prf(rpid=example.com,salt=xyz...)           ; Identified RP
webauthn-prf(salt=xyz...)                            ; Anonymous RP
webauthn-prf(rpid=example.com,salt=xyz...,label=YubiKey) ; With label

The parameters are:

rpid (OPTIONAL):

The WebAuthn relying party identifier. When present, the Decryptor uses this rpId for the WebAuthn ceremony. When omitted, selects anonymous RP mode.

salt (required):

The Base64-encoded PRF salt; must decode to exactly 32 octets. Generated at encryption time using SafeRandom.

label (OPTIONAL):

A human-readable display name for this credential (e.g., "YubiKey", "Phone"). Not included in the step_binding; see Section 5.6.

Credential selection is delegated to the authenticator via WebAuthn's allowCredentials mechanism. The Decryptor passes all candidate credential IDs for the rpId; the authenticator selects the matching credential internally.

Grammar:

webauthn-prf-step = "webauthn-prf(" webauthn-params ")"
webauthn-params   = [ "rpid=" rpid "," ] "salt=" salt
                    [ "," "label=" label-value ]
rpid              = 1*( ALPHA / DIGIT / "-" / "." )
salt              = 1*BASE64CHAR    ; 44 chars = 32 octets
label-value       = 1*( ALPHA / DIGIT / "-" )
Anonymous RP mode:

When rpid is omitted from the token, the Decryptor tries each rpId for which it holds credentials. Each rpId requires a separate WebAuthn ceremony (and potentially a user prompt). Privacy benefit: hides the relying party from passive observers. Cost: one ceremony per candidate rpId.

Derivation:

The authenticator evaluates the PRF extension with the selected credential and the decoded salt:

prf_salt = decode(salt)      ; 32 octets

prf_output = WebAuthn_PRF(credential, prf_salt)

step_secret = SafeDerive(
    "webauthn-prf", prf_output,
    encryption_parameters, 32)

Inputs: credential (local, selected by authenticator for the rpId), prf_salt (from token).

Encode form:

Encode("webauthn-prf", rpid, salt). rpid is UTF-8; salt is the raw decoded 32 octets. rpid is always present in the binding form even when omitted on-wire; when omitted, the Encryptor or Decryptor uses the rpId from the WebAuthn ceremony. Label is not included in binding.

Validation:

salt must decode to exactly 32 octets. rpid, when present, must match the hostname grammar 1*(ALPHA / DIGIT / "-" / ".").

Example LOCK:

-----BEGIN SAFE CONFIG-----
Lock-Encoding: readable
-----END SAFE CONFIG-----
-----BEGIN SAFE LOCK-----
Step: webauthn-prf(rpid=example.com,salt=xyz...)
Encrypted-CEK:
  Base64-encoded encrypted CEK
-----END SAFE LOCK-----

I.2.2. IANA Registry Entry

Table 24
Step Name Parameters Inputs Secret Ref
webauthn-prf rpid=X, salt=X Credential, rpId 32 octets (this doc)

I.2.3. Security Considerations for WebAuthn PRF Step

The WebAuthn PRF step provides hardware-bound key material (the authenticator holds the secret), user presence verification (touch required), phishing resistance (rpid binding), and offline decryption capability once the PRF output is computed.

The PRF extension requires WebAuthn Level 3 support in the browser. Non-discoverable credentials wrap key material in the credential ID; losing the credential ID means losing access to the encrypted file. Unlike the Privacy Pass steps, no server-side rate limiting is possible.

Privacy: rpid in cleartext reveals the relying party to passive observers. Anonymous RP mode (rpid omitted) hides this but the rpId may still be guessable from context.

Trial bounds: anonymous RP mode requires one WebAuthn ceremony per candidate rpId. Decryptors should impose a local bound on the number of rpIds to try. Prefer identified mode when privacy is not required.

Appendix J. Changes from -01

Author's Address

Nick Sullivan
Cryptography Consulting LLC