There are several cases where users want to exchange private data asynchronously when the other party may be offline. In such cases we can make use of store-and-forward networks to deposit data for later retrieval. The shared private data extension allows a file system's owner to deposit asymmetrically encrypted messages so they can be picked up by recipients when they come online. Recipients can periodically - or after being prompted - scan the sender's private forest for payloads they can decrypt, for example by looking for updates in file systems from a contact list, or by being sent a message pointing to the share payload over an out-of-bounds secure channel. These encrypted payloads contain secrets giving read access and/or UCANs giving write access to parts of the owner's public or private file system.
The shared private data extension is versioned and designed to support multiple versions living on the same file system concurrently, so devices can gradually upgrade to the new version and support multiple versions at the same time.
The protocol version determines the algorithm used, key size and other parameters.
Version 1 of the shared private data protocol uses RSA public keys for asymmetric encryption. These RSA public keys MUST have an exponent of 65537 and MUST be 2048-bit. The method for encryption MUST be RSAES-OAEP.
To share information with a user who is offline we make use of asymmetric encryption. All WNFS users widely distribute a list of public keys - their non-exportable "exchange public keys" at a well-known location: A map under exchange
at the root of WNFS next to public
and private
. See the rationale for more information.
These keys are used to encrypt a share payload for a recipient. This share payload contains a pointer to a private node and the symmetric key to decrypt it.
Allowing multiple exchange keys per WNFS enables multiple recipient devices to receive a share without having to transfer exchange keys between devices. This enables exchange keys to be stored as non-extractable keys in device enclaves for safety.
The exchange key partition is structured as a public directory under its own top-level link. This directory contains further directories containing exchange keys.
Exchange keys are grouped by device, such that a sender only needs to choose one of the available keys for each device. For example, senders that support protocol version 1 and 2 look through all recipient's devices and select either a single version 2 exchange key or, if no such key is present, a version 1 key from each device.
These keys are grouped as a directory with a unique name per device. It is RECOMMENDED to use a base64URL-encoded 32-byte nonce, as that does not leak any information about the device and is sufficiently collision-resistant. In the future, implementations MAY choose to use a meaningful identifier instead, so implementations SHOULD be lenient with directory names.
See the example exchange partition layout for examples.
Individual device exchange keys are versioned. The protocol version is derived from the file name. Protocol version 1 exchange keys are named v1.exchangekey
.
The file's content is 256 bytes of the exchange key's RSA public modulus encoded as big-endian unsigned integer.
Note: Using big-endian here is inconsistent with this specification's use of little-endian encoding for integers in general, but it's the more common encoding for RSA numbers.
See the test vectors for examples.
The exchange partition is merged according to the rules for public directory merging.
Shared private data payloads are stored in the Private Forest.
Shares are labeled in the private forest by a NameAccumulator
consisting of:
- The sender's root DID. In most cases this will be the file system owner's root DID.
- The recipient's exchange key
- A counter, which is incremented each time the sender creates a share.
The counter MUST be a 0-based natural number.
The NameAccumulator
for a share MUST be computed as follows:
function computeShareLabel(senderRootDID: string, recipientExchangeKey: ByteString, counter: number) {
return NameAccumulator.empty()
.add(senderRootDID)
.add(recipientExchangeKey)
.add(encode(counter))
}
Here encode
is a function that maps a share counter to a little-endian byte array encoding of a 64-bit unsigned integer.
The recipientExchangeKey
are the recipient device's exchange key bytes, including the protocol versioning prefix.
The share payload MUST be a non-empty list of CIDs to RSAES-OAEP-encrypted ciphertexts.
The decrypted payload MUST be a CBOR-encoded object of following shape:
type SharePayload = TemporalSharePointer | SnapshotSharePointer
type TemporalSharePointer = {
"wnfs/share/temporal": {
label: Hash<NameAccumulator> // 32 bytes BLAKE3 hash
cid: Cid // content block CID
temporalKey: Key // 32 bytes AES key
}
}
type SnapshotSharePointer = {
"wnfs/share/snapshot": {
label: Hash<NameAccumulator> // 32 bytes BLAKE3 hash
cid: Cid // content block CID
snapshotKey: Key // 32 bytes AES key
}
}
See the validation specification on requirements for validating this data during deserialization.
Because v1 exchange keys are 2048 bit RSAES-OAEP keys, they can only encrypt up to 190 bits of data. Both payloads of the above format encode to 157, so fit within RSAES-OAEP limits.
NB: Share payload ciphertexts will always be 256 bytes long, due to the way RSAES-OAEP works.
The temporal share pointer consists of
- a 32 byte private forest label used to look up the private node to decrypt in the private forest
- a 32 byte temporal key to decrypt the private node and its temporal header.
The snapshot share pointer works exactly like the temporal share pointer, except instead of encoding a temporal key, it encodes a snapshot key.
This snapshot key only gives access to a single revision, in case a user wanted to only share a single revision with someone else.
It's not assumed that recipients are notified over some secondary channel when they receive new shares.
Thus it is possible to scan other user's file systems for files that were shared with a given exchange key. To do this, generate share labels as described in section Share Label starting from 0 or the last counter used for lookup until the first missing label in the private forest is hit.
If multiple shares are created concurrently on separate devices, then these are merged according to the private forest merge algorithm.
Multiple share payloads are allowed per share label.
The shared private data extension also allows dropping off secrets on a file system with the owner as recipient instead of as a sender.
A sender with write access to the root owner's private forest would thus drop off share payloads in the private forest at the labels corresponding to all combinations of the sender's root DID and the file system owner's listed exchange keys.
The file system owner can later receive share payloads by looking through their own file system with combinations of the known sender's root DID and of their own exchange keys.
If a sender wants to share multiple private nodes at the same time, it is RECOMMENDED to create a private directory containing all nodes to share and create a single share payload pointing to that directory.
This directory MAY be constructed on-the-fly. Its name then contains the sender's root DID and the node's i-number. This ensures the sender will always have permission to create this name in the private forest. The directory's contents may then have names that aren't derived from the name of the sharing directory.
The hex encoded bytes for the RSA-2048 public modulus used as an exchange key of protocol version 1:
c7970ceedcc3b0754490201a7aa613cd73911081c790f5f1a8726f463550bb5b7ff0db8e1ea1189ec72f93d1650011bd721aeeacc2acde32a04107f0648c2813a31f5b0b7765ff8b44b4b6ffc93384b646eb09c7cf5e8592d40ea33c80039f35b4f14a04b51f7bfd781be4d1673164ba8eb991c2c4d730bbbe35f592bdef524af7e8daefd26c66fc02c479af89d64d373f442709439de66ceb955f3ea37d5159f6135809f85334b5cb1813addc80cd05609f10ac6a95ad65872c909525bdad32bc729592642920f24c61dc5b3c3b7923e56b16a4d9d373d8721f24a3fc0f1b3131f55615172866bccc30f95054c824e733a5eb6817f7bc16399d48c6361cc7e5
The RSA-2048 public modulus is this decimal-encoded number:
RSA-2048 = 2519590847565789349402718324004839857142928212620403202777713783604366202070
7595556264018525880784406918290641249515082189298559149176184502808489120072
8449926873928072877767359714183472702618963750149718246911650776133798590957
0009733045974880842840179742910064245869181719511874612151517265463228221686
9987549182422433637259085141865462043576798423387184774447920739934236584823
8242811981638150106748104516603773060562016196762561338441436038339044149526
3443219011465754445417842402092461651572335077870774981712577246796292638635
6373289912154831438167899885040445364023527381951378636564391212010397122822
120720357
An example exchange partition tree with a hypothetical protocol version 2 looks like this:
/exchange
├── aNXmyZ-kRI1-fsLcOol8wi0wfmxxY5_15bxdt2T4bs8
│ ├── v1.exchangekey
│ ├── v2.exchangekey
├── BEubij6fgCV51wyhYtITYCYhoGvk5Y5Slr59Kl4FOkQ
│ └── v1.exchangekey
└── zulwKufD7v71rJ7792cl2pi1LHmePS7d7a0vhuvaKNo
└── v2.exchangekey
This represents an exchange partition with three devices.
The first device (with the label aNXmyZ...
) has two exchange keys. It supports both protocol version 1 and 2.
The second device (with the label BEubij...
) has not yet upgraded to protocol version 2. It only provides a single version 1 exchange key.
The third device (with the label zulwKu...
) was initialized long after protocol version 2 support was implemented. It thus never generated a version 1 exchange key and operates completely on protocol version 2.