Proposal: ../rfcs/2026-08-22-xftp-file-storage-time.md.
New module Simplex.Messaging.Crypto.Entitlement, over Simplex.Messaging.Crypto.BBS:
Types:
newtype MasterKey = MasterKey ByteString
data Entitlement = Entitlement
{ entitlementName :: Text,
expiresAt :: UTCTime,
extraInfo :: Text
}
data EntitlementCredential = EntitlementCredential
{ issuerKeyIdx :: Int,
masterKey :: MasterKey,
issuerSignature :: BBSSignature,
entitlement :: Entitlement
}
data EntitlementProof = EntitlementProof
{ issuerKeyIdx :: Int,
proof :: BBSProof,
entitlement :: Entitlement
}
Functions and constants:
- the disclosed-message encoding: the master key is message 0 and stays undisclosed;
expiresAt,entitlementName, andextraInfoare messages 1 to 3 and are disclosed - the BBS header string
"SimpleX entitlement v1", the message count, and the disclosed indexes generateEntitlementProof :: BBSPublicKey -> EntitlementCredential -> BBSPresHeader -> IO (Either String EntitlementProof)verifyEntitlement :: Map Int BBSPublicKey -> BBSPresHeader -> EntitlementProof -> IO (Maybe Bool)(the caller supplies the presentation header; the server reconstructs it, the proof never includes it)- the issuer public keys constant
Map Int BBSPublicKey
In Simplex.FileTransfer.Transport:
- add the next
VersionXFTPand setcurrentXFTPVersionto 4
In Simplex.FileTransfer.Protocol:
- add
GrantedStorageTimeand its encoding; retain the one-character sum prefix for future variants:
data GrantedStorageTime = GSTExpires {epochSeconds :: Int64}
- add the storage time (
Maybe Int64:Nothingrequests the server maximum,Justa number of hours) andMaybe EntitlementProoffields toFNEW - add the granted storage to
FRSndIdsasMaybe GrantedStorageTime(Nothingwhen decoding a response from a server below this version) - build the presentation header for FNEW
In Simplex.FileTransfer.Server:
- pass
sessionIdfromthParamsintoprocessXFTPRequest
In Simplex.FileTransfer.Server.Env and Simplex.FileTransfer.Server.Main:
- make
fileExpirationnon-optional (ExpirationConfig, no longerMaybe); the server always expires files, so the server maximum is always a concrete number of seconds - read a maximum storage time (a number of hours) for each entitlement name from the
[STORE_LOG]INI section, from the keysexpire_files_hours_for_supporterandexpire_files_hours_for_legend - exit at startup if any name's maximum is below the default file expiration
- read the issuer public keys from the shared constant
The files table gets a nullable expires_at. Every new file stores a concrete expires_at. It is NULL only for pre-feature rows, which the migration must not re-date (it has no access to the operator's configured TTL); those are expired at query time as created_at + ttl.
Common to both stores, in Simplex.FileTransfer.Server.Store:
- add
expiresAt :: Maybe RoundedFileTimetoFileRec - in
createFile, verify the proof againstsessionId <> sndKey <> digest, cap the requested hours at the entitlement's maximum, round the expiry up to the hour, store it, and return that same value as the granted storage - a valid proof raises the maximum to the entitlement's configured value; a proof that fails verification, carries an unknown issuer key, or whose entitlement expired more than 24 hours ago falls back to the default maximum. The entitlement is honoured for 24 hours after its
expiresAt. expiredFilesreceivesnowandold(=now - ttl). A stored expiry is deleted whenexpires_at < now(no grace — it is already rounded up); a legacy row (noexpires_at) is deleted whencreated_at + fileTimePrecision < old(the grace coverscreated_atbeing floored to the hour)- retain
created_atfor statistics, export, and the legacy fallback
STM store:
- in
expiredFiles, expire a new file whenroundedSeconds expiresAt < now, and a legacy file (noexpiresAt) whencreated_at + fileTimePrecision < old
PostgreSQL store, in Simplex.FileTransfer.Server.Store.Postgres and its migrations:
- add the nullable column
expires_at BIGINT(no backfill) - add one composite index
idx_files_expiry ON files (expires_at, created_at) expiredFilesquery:WHERE (expires_at < ?) OR (expires_at IS NULL AND created_at < ?) LIMIT ?with(now, old - fileTimePrecision). The first arm deletes stored (already rounded-up) expiries; the second drains legacy rows, with the grace folded intoold - fileTimePrecisionso the columns stay bare and sargable. Keep theORat the top level so each disjunct is independently indexable (BitmapOr on the composite index):expires_atcovers arm 1's range and arm 2'sIS NULLgroup, andcreated_atorders arm 2 within that group. ACOALESCE(expires_at, created_at + ttl)predicate is avoided (not sargable, would force a sequential scan). NoORDER BY— the batch loop deletes all expired rows regardless of order.
Store log, in Simplex.FileTransfer.Server.StoreLog:
- add the optional expiration to the
AddFilerecord; a record without it parses toNothing(the configured default), never a hardcoded value
Public API in Simplex.Messaging.Agent:
- add
Maybe EntitlementCredentialand storage time (Maybe Int64hours) parameters toxftpSendFile
Store, in both the SQLite and PostgreSQL agent stores:
- add a nullable entitlement credential column (JSON text) and a nullable storage time column (integer hours; NULL means the server maximum) to
snd_files - add the migration to both stores
- in
createSndFile, store the credential and the storage time
Upload, in Simplex.Messaging.Agent.Client and Simplex.FileTransfer.Client:
- in
agentXFTPNewChunk, read the credential, the storage time, and the digest from the send record - inside
withClient, wheresessionIdis available, build the presentation headersessionId <> sndKey <> digest, generate the proof, and send FNEW with the storage time and the proof - discard the returned expiration for now
- remove lifetime badges: make
badgeExpiryaUTCTime, drop the"lifetime"encoding, and remove the lifetime option from the UI and the CLI - map
BadgeInfotoEntitlement(entitlementName = textEncode badgeType,expiresAt = badgeExpiry,extraInfo = badgeExtra) when calling the agent - pass the user's credential and
FSMaxTimetoxftpSendFile - retain the
maxXFTPFileSizesize limit - reuse
verifyEntitlementfor peer-badge verification - import the issuer public keys from the shared simplexmq constant
- Add the entitlement crypto module; move chat's badge verification onto it and remove lifetime badges.
- Add the new XFTP version, the FNEW protocol change (storage time + proof), and the response.
- Change the server configuration, store, expiration, and store log.
- Change the agent store and add proof generation on upload.
- Wire chat to pass the credential and the storage time.