Every file is encrypted in the browser before it is uploaded to the account, and your app never handles a key. This page covers the one state an app does see, locked, and the errors that come with a key that changed.
Encryption is always on: there is no unencrypted account and no setting that turns it off. readFile and writeFile work unchanged, because the encryption happens in the broker frame, the hidden fkn.app iframe the library mounts, see how it works. The broker is the connection your app holds into FKN through that frame. The account is the FKN identity a person carries between sites, and the service is the FKN server that keeps its files.
Locked means the broker holds no usable key for this app and account. A key reaches the broker only from a first-party fkn.app window, the connect or unlock popup. The broker keeps it in its own storage partition until that record expires.
A refused read or write does not fail at once. It raises the broker’s unlock card inside the call and waits. Concurrent locked calls share the one card. StorageLockedError arrives only after the card was dismissed, could not be shown, or delivered no usable key:
Every cloud.fs call on this page waits for the broker with no deadline, so in a worker nobody relayed none of them returns. The one bounded wait is the availability probe behind the hybrid fs, see connection and lifecycle.
unlock() asks for the key without a read or a write. It answers false with no account connected and true when a usable key is already held. Otherwise it raises the card and answers what the card answered:
app.ts
await
functionunlock():Promise<boolean>
unlock() // true once unlocked, false after a dismissal or with nothing connected
true means the broker now holds a key, not that the next write will accept it, see key epochs.
The card, Storage locked. Click to unlock., never asks for a secret. Its Unlock button opens a first-party fkn.app window for the password or passkey, which this site never sees.
Two connections never unlock through unlock(): one the service has stopped accepting, where the card comes back after every popup, and one made before FKN began delivering keys, since a key crosses only from a first-party window. Disconnect and connect once to fix either, see disconnecting.
The broker’s availability probe tells a refused connection from a locked account. It is availability() on the cloud.fs behind apiPromise from @fkn/lib/api, and it answers 'disconnected' once the service has refused the connection and 'connected' while the account is merely locked, see the broker’s vocabulary. A broker older than that resolver carries only available(), so check that availability is a function before you call it.
unlock() // the account has a key this browser lacks, so ask now
constkeyEpoch:number|null
keyEpoch// the account's current key epoch, unix seconds at enrolment, or null
The three fields are the EncryptionStatus on TypeScript, { unlocked, enrolled, keyEpoch: number | null }, which the broker’s own vocabulary calls EncryptionState. They come from two places:
Field
What it holds
unlocked
this browser holds a usable key for this app and account, the broker’s own answer
enrolled
the service reports a key for the account, keyEpoch != null
keyEpoch
the account’s current key epoch from the service, unix seconds at enrolment, or null
Two fields are the service’s answer about the account and one is this browser’s answer about itself, so they can disagree. When the service cannot be reached the call still resolves with enrolled: false and keyEpoch: null beside whatever unlocked says, so { unlocked: true, enrolled: false } is what an outage looks like.
Never read enrolled: false as “this account has no key”. An app that gates writes or shows an enrol prompt on it does so at every outage. The account does hold no key between a settled key reset and its next enrolment, see key epochs.
fs reaches the account through mount() and the read rule, and through pull() and readFileSealed(). On a locked account await fs.mount() is where the card appears. It reads every listed file in turn, and each account read raises the card and blocks on the user. Each dismissal costs one more card for the next path this device holds no copy of.
A path whose read failed stays present but unreadable. existsSync is true and readdirSync lists it from the root, while readFileSync, statSync, lstatSync, writeFileSync, appendFileSync and renameSync throw with codeFKN_E2E_LOCKED. Nothing creates its parent directory, so readdirSync on that parent throws ENOENT unless a readable file shares it:
message// 'storage: library/catalog.json exists but could not be read, retry once its scope is available'
}
The message is storage: <path> exists but could not be read, retry once its scope is available. The code means listed in the account and unreadable here. fs marks a path unreadable on any failed read, whether the account is locked, the file was encrypted under a reset key, its integrity check failed or the service answered an error, and it cannot tell the four apart.
A path still unreadable after an unlock is one of the other three, and only a read that reaches the account names the cause: cloud.fs, pull() and readFileSealed() reject with StorageLockedError while locked, and otherwise with the sentences under the messages.
fs refuses a write to an unreadable path rather than queueing one. Deleting it needs no key. A later callback or promise call re-runs that read after 5 seconds, so a locked file appears on its own after an unlock. adopt() and the drain, the background process that replicates queued writes, stop at the first locked failure and keep the work queued, see adopting files written before sign-in.
Only ENOENT means empty, so the readOr helper on error codes rethrows this code. Its locked branch calls unlock() and then remount(), and the fresh hydrate reads the path:
keyEpoch is the account’s key epoch, the unix time in seconds when the key was enrolled. A key generation is that epoch paired with a fingerprint of the key, since a reset and a new enrolment in the same second repeat the epoch. Every stored file records the generation it was encrypted under. So keyEpoch alone is for display and coarse comparison, never a key’s identity.
A write encrypts under the account’s current generation, read fresh from the service, and its commit names it. A commit naming a generation the account has since left is refused with Superseded key. When the held key is behind, the broker raises the unlock card before it can encrypt again:
Returns true if the sequence of elements of searchString converted to a String is the
same as the corresponding elements of this object (converted to a String) starting at
position. Otherwise returns false.
Converts a JavaScript value to a JavaScript Object Notation (JSON) string.
@param ― value A JavaScript value, usually an object or array, to be converted.
@param ― replacer A function that transforms the results.
@param ― space Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.
@throws ― {TypeError} If a circular reference or a BigInt value is found.
stringify({
items: never[]
items: [] }))
The block retries Superseded key once, the one write refusal here worth a retry. A read accepts any generation the broker still holds.
A generation changes when the user resets the account’s key and enrols again on the Security page of fkn.app. Between the two the account holds no key at all, so encryption() answers enrolled: false and FKN refuses every write rather than storing anything readable.
The old key is gone everywhere, so every file encrypted before the reset answers fkn:e2e-stale-epoch from then on. The only way back is an encrypted export taken before the reset. An app has nothing to retry: report it, and never write a guess over the file.
A hop out of the broker keeps an Error’s name, message, stack and cause only, see what crosses a realm, so the broker throws fixed sentences rather than codes. The library matches the locked one and re-mints StorageLockedError on your side. @fkn/lib/messages exports all five names below. The first three are the messages themselves, matched by prefix, and the last two are codes you read off error.code:
no seal marker, the field naming the envelope layout, an unknown one, a scope or connection that is not this caller’s own, or an envelope whose authentication tag does not verify
Test the locked case with StorageLockedError and absence with isNotFound, on reads and writes only, since the re-mint does not run for unlink, see unlink() skips the re-mint. Match stale-epoch, integrity and unreachable by message prefix:
app.ts
const
constcached: () =>Promise<Uint8Array|null>
cached=async ():
interfacePromise<T>
Represents the completion of an asynchronous operation
A typed array of 8-bit unsigned integer values. The contents are initialized to 0. If the
requested number of bytes could not be allocated an exception is raised.
Attaches a callback for only the rejection of the Promise.
@param ― onrejected The callback to execute when the Promise is rejected.
@returns ― A Promise for the completion of the callback.
catch(() =>null) // this device's last good copy, or nothing
if (
constdata:string|Buffer<ArrayBufferLike> |null
data===null) returnnull
return
constdata:string|Buffer<ArrayBufferLike>
datainstanceof
var Uint8Array:Uint8ArrayConstructor
A typed array of 8-bit unsigned integer values. The contents are initialized to 0. If the
requested number of bytes could not be allocated an exception is raised.
Uint8Array?
constdata:Buffer<ArrayBufferLike>
data:new
var TextEncoder:new () =>TextEncoder
The TextEncoder interface takes a stream of code points as input and emits a stream of UTF-8 bytes.
The TextEncoder.encode() method takes a string as input, and returns a Global_Objects/Uint8Array containing the text given in parameters encoded with the specific method for that TextEncoder object.
A typed array of 8-bit unsigned integer values. The contents are initialized to 0. If the
requested number of bytes could not be allocated an exception is raised.
A typed array of 8-bit unsigned integer values. The contents are initialized to 0. If the
requested number of bytes could not be allocated an exception is raised.
Uint8Array?
constdata:Buffer<ArrayBufferLike>
data:new
var TextEncoder:new () =>TextEncoder
The TextEncoder interface takes a stream of code points as input and emits a stream of UTF-8 bytes.
The TextEncoder.encode() method takes a string as input, and returns a Global_Objects/Uint8Array containing the text given in parameters encoded with the specific method for that TextEncoder object.
Returns true if the sequence of elements of searchString converted to a String is the
same as the corresponding elements of this object (converted to a String) starting at
position. Otherwise returns false.
startsWith(
constE2E_STALE_EPOCH_MESSAGE:"fkn:e2e-stale-epoch: this file is encrypted under a previous key you reset"
Returns true if the sequence of elements of searchString converted to a String is the
same as the corresponding elements of this object (converted to a String) starting at
position. Otherwise returns false.
startsWith(
constE2E_INTEGRITY_MESSAGE:"fkn:e2e-integrity: stored data failed its integrity check"
Returns true if the sequence of elements of searchString converted to a String is the
same as the corresponding elements of this object (converted to a String) starting at
position. Otherwise returns false.
loadBackup() // the bytes, null with nothing stored, or cached while unreachable
isNotFound and instanceof StorageLockedError come first, because those two classes are minted on your side and keep their code. The order to test a mixed handler in is on handling errors.
The service stores the encrypted bytes and, beside them, the five fields of FileEntry: path, size, contentType, updatedAt, and the seal marker in encryption, the field that names the envelope layout and nothing else. cloud.fs.quota() meters usedBytes and objects against their caps, see limits and timeouts.
OPFS, the local half of fs and all of opfs, is not encrypted by the platform and belongs to the origin, not the account, see scope and paths.