@absolutejs/secure-transfer
v0.3.0
Published
Provider-neutral encrypted, record-oriented large-object transfer for AbsoluteJS.
Readme
@absolutejs/secure-transfer
Provider-neutral encrypted large-object transfer for AbsoluteJS. It splits a known-length source into bounded, independently authenticated records, writes only ciphertext to an untrusted store, and downloads into a transactional sink.
const transfer = createSecureTransferClient({
cryptoProvider,
store,
policy: {
maximumAttachmentBytes: 1024 ** 4,
maximumDescriptorBytes: 16 * 1024,
maximumFutureSkewMs: 300_000,
maximumMetadataBytes: 4 * 1024,
maximumRecordPlaintextBytes: 1024 * 1024,
maximumRecords: 1_048_576,
maximumTtlMs: 7 * 24 * 60 * 60 * 1000,
},
});
const descriptor = await transfer.upload({
attachmentId: crypto.randomUUID(),
body: file.stream(),
byteLength: file.size,
contentType: file.type,
conversationId,
expiresAt: Date.now() + 86_400_000,
fileName: file.name,
senderDeviceId,
});
await messaging.send({
conversationId,
id: crypto.randomUUID(),
plaintext: encodeSecureTransferDescriptor(descriptor),
purpose: "secure-transfer.descriptor",
ttlMs: 86_400_000,
});Authenticated byte ranges
downloadRange() authenticates every complete encrypted record covering the
requested interval, then passes only the selected plaintext bytes to a
transactional range sink. The range is [start, endExclusive) and must be
non-empty and within the descriptor's declared plaintext size.
await transfer.downloadRange(
descriptor,
{ start: 1_048_576, endExclusive: 2_097_152 },
rangeSink,
);This proves the authenticity and position of the requested records against the descriptor. It intentionally does not fetch or prove the current availability of records outside the range.
Honest revocation
Configure a trusted SecureTransferRevocationStore, then create the durable
tombstone before attempting ciphertext cleanup:
const { revocation, ciphertextRemoved } = await transfer.revoke({
descriptor,
reason: "member-removed",
revokerDeviceId,
});
await messaging.send({
conversationId,
id: crypto.randomUUID(),
plaintext: encodeSecureTransferRevocation(revocation),
purpose: "secure-transfer.revocation",
ttlMs,
});Recipients must authenticate the E2EE sender, authorize that device to revoke
the attachment, strictly decode the notice, and only then call
applyRevocation(). The notice is bound to the exact descriptor by a SHA-256
hash. Downloads consult trusted policy state before and throughout retrieval and
fail closed when that store errors. Keep tombstones at least through the
descriptor expiry; ciphertextRemoved: false means cleanup must be retried even
though cooperating clients already block the transfer.
Revocation is not retroactive cryptographic erasure. A bearer capability, ciphertext, or plaintext already copied by a recipient cannot be recalled. After an MLS member removal, use a fresh capability for replacement content and deliver its descriptor only in the new epoch; removal protects future epoch traffic, not secrets the former member already received. This follows the epoch and member-removal model in RFC 9420. Where a deployment uses cryptographic erase for storage cleanup, follow the key sanitization program in NIST SP 800-88 Rev. 2; that still does not sanitize independently held recipient copies.
Post-membership capability replacement
An MLS removal changes who receives future epoch secrets, but it does not change an attachment capability already delivered in an earlier epoch. For retained attachments that a removed device must no longer fetch, upload a new encrypted copy with a fresh capability and send the replacement only in the new epoch:
const membership = await messaging.removeMembers({
conversationId,
deviceIds: [removedDeviceId],
ttlMs,
});
const replacement = await transfer.prepareReplacement({
previousDescriptor,
reason: "membership-change",
replacement: {
...replacementMetadata,
body: reopenPlaintextSource(),
senderDeviceId,
},
securityEpoch: membership.epoch,
});
await messaging.send({
conversationId,
expectedSecurityEpoch: membership.epoch,
id: crypto.randomUUID(),
plaintext: encodeSecureTransferReplacement(replacement),
purpose: "secure-transfer.replacement",
ttlMs,
});
await transfer.activateReplacement({
authenticatedContext: {
conversationId,
purpose: "secure-transfer.replacement",
securityEpoch: membership.epoch,
senderId: senderDeviceId,
},
persistReplacement: saveProtectedDescriptor,
previousDescriptor,
removeSupersededCiphertext: true,
replacement,
});The secure-messaging send persists advanced MLS state and its retryable outbox
entry before returning, even when delivery is queued. Only then should the
sender activate supersession and remove old ciphertext. A recipient strictly
decodes the same payload and passes the message's authenticated context to
activateReplacement(). Activation verifies the old descriptor hash, fresh
transfer ID and capability, attachment, conversation, sender, purpose, and exact
epoch. Its persistReplacement callback runs before the old tombstone is
installed and must be durable, idempotent, and protect the bearer descriptor.
If activation crashes after descriptor persistence but before supersession, retry the same payload. This temporarily leaves the old transfer usable instead of stranding the replacement. Expiry sweeps clean abandoned new ciphertext when a message can never be durably queued. Rate-limit rotations and prioritize only attachments that remain useful; membership churn must not become an unbounded re-encryption denial of service.
Resumable uploads
Configure a SecureTransferReceiptProtector and
SecureTransferProtectedReceiptStore, then persist the initial protected receipt
before reading the source:
const { receiptId } = await transfer.beginResumableUpload({
attachmentId,
byteLength: file.size,
conversationId,
expiresAt,
fileName: file.name,
senderDeviceId,
});
const descriptor = await transfer.resumeUpload({
receiptId,
source: (byteOffset) => file.slice(byteOffset).stream(),
});Receipts contain the transfer's bearer decryption capability. Core passes only
protected opaque bytes to receipt storage and binds protection to receiptId.
Use an authenticated protector backed by a key that is separate from object
storage credentials. Never implement the protector as plaintext or reversible
encoding.
Receipt stores must implement atomic lease acquisition and compare-and-swap
updates. Core checkpoints phase: "sealing" before invoking record encryption.
If a crash occurs after ciphertext storage, resume authenticates that ciphertext
against the source before advancing. If encryption might have happened but no
ciphertext is durable, SecureTransferResumeUnsafeError requires a new transfer
and capability rather than risking nonce reuse.
Receipt adapters should implement
SecureTransferProtectedReceiptLifecycleStore; run sweepExpiredReceipts()
with its returned cursor until truncated is false.
The descriptor contains the decryption capability and sensitive metadata. It is
plaintext until the caller protects it with @absolutejs/secure-messaging or an
E2EE envelope. Never place it in object metadata, logs, URLs, push payloads, or a
normal chat message.
Security model
- Storage receives opaque transfer IDs, record indexes, ciphertext sizes, and expiry. It does not receive filenames, media types, conversation IDs, or keys.
- Every record is bound to the transfer, attachment, conversation, sender, position, total count, expected plaintext size, final-record marker, and expiry.
- Record creation is create-only. A collision must never overwrite ciphertext.
- Missing, reordered, substituted, duplicated, truncated, and extended records fail authentication or descriptor validation.
- Downloads target a staging sink.
commit()occurs only after every record is authenticated; failure callsabort()so partial plaintext is not mistaken for a complete file. - Range downloads preserve the same staging rule but authenticate only records intersecting the requested byte interval.
- Revocation stores are trusted authorization state and should use credentials and retention controls distinct from untrusted ciphertext storage.
- Upload failure makes a best-effort ciphertext cleanup. Production adapters
should implement
SecureTransferLifecycleStoreand run bounded expiry sweeps repeatedly with the returnedcursoruntiltruncatedis false so live records at the start of a listing cannot starve crash-orphan cleanup.
This framing is inspired by RFC 8188, especially its authenticated record sequence, truncation handling, and unique per-record nonce requirements. It is not the RFC 8188 HTTP wire format. The resumable state machine follows RFC 5116 section 3.1, which calls for durable nonce checkpointing before encryption proceeds.
File names and media types are untrusted display hints. Applications must enforce allowlists, size limits, safe download dispositions, and client-side inspection after decryption. Server-side malware scanning cannot inspect end-to-end encrypted ciphertext without deliberately changing the confidentiality boundary.
Scope
Version 0.2.0 provides upload, strict descriptor, receipt, and revocation
encoding, full and byte-range authenticated download, resumable crash recovery,
transactional sinks, honest future-fetch revocation, cleanup, and provider/store
contracts. Concrete local and S3/R2 storage adapters live in
secure-transfer-adapters.
Version 0.3.0 adds epoch-bound fresh-capability replacement and staged
supersession after MLS membership changes.
License
Apache-2.0
