Encryption Networks
An encryption network is an owner-scoped X25519 keypair that a node custodies on behalf of a principal: clients encrypt locally to the network's public key, the node holds the private half sealed at rest, and decryption is a separate capability-gated invocation in which the node unwraps a wrapped symmetric key and immediately rewraps it to a one-time receiver key — so the node moves key material but never sees plaintext. It is identified by the URN urn:tinycloud:encryption:{ownerDid}:{name} and, in v1, is realized by the single shipped backend LocalOneOfOneBackend (n=1, t=1).
Role
Encryption networks live in Layer 1. They are the user-facing encryption primitive, distinct from at-rest encryption (which protects columns the node itself owns). The module "deliberately does not expose a node-side encrypt API — clients encrypt to the network public key locally" (encryption_network/mod.rs), which is what keeps plaintext off the node entirely. A network is user-bound, not space-bound: it is rooted at an ownerDid, can hold key custody across many spaces, and its lifecycle (create/revoke) is owner-only. The reserved KeyBackendKind::Threshold slot is where threshold decryption will later split this custody across a cohort.
Shape
Network identifier
The network URN is parsed by NetworkId (encryption_network/network_id.rs):
urn:tinycloud:encryption:<ownerDid>:<name>
<ownerDid>is the root authority for the network and may itself contain colons (e.g.did:key:z6Mk…), so the parser splits on the last:— everything before is the owner DID, the final segment isname(network_id.rs:rsplit_once(':')).<name>may not contain:or/. The SDK additionally constrains it to^[a-z0-9][a-z0-9-]*$(networkId.ts:NETWORK_NAME_RE).- The owner DID is run through
canonicalize_principal, so adid:pkh:eip155URN round-trips with a checksummed address (test atnetwork_id.rs:155-168). This URN is aResource::Otherform, not a space URI — see uri-addressing-grammar.
Algorithm and envelope
The ciphersuite is the constant ALG_X25519_AES256GCM = "x25519-aes256gcm/v1" (types.rs). Data is persisted in an InlineEnvelope carried alongside KV/SQL records (types.rs:131-155):
struct InlineEnvelope {
v: u32,
networkId: NetworkId,
alg: String,
keyVersion: i64,
encryptedSymmetricKey: Vec<u8>, // symmetric key sealed to the network public key
encryptedSymmetricKeyHash: String,
ciphertext: Vec<u8>, // AES-256-GCM payload (client-encrypted)
aad: Vec<u8>, // app-bound AAD; the node never reads it
metadata: Map<String, Value>,
}
Network descriptor
The public state of a network is NetworkDescriptor (types.rs:96-117): networkId, ownerDid, name, members (node DIDs in custody), threshold {n, t}, state, publicEncryptionKey, alg, keyVersion, keyBackend. Lifecycle states are Pending → Generating → Active (plus Rotating/Revoked/Failed); v1 creation jumps straight to Active. The threshold field is "preserved in the data shape so callers can distinguish V1 from future threshold deployments at parse time" — it is {n:1, t:1} today.
Mechanics
Local encrypt (client side)
encryptToNetwork (js-sdk envelope.ts) is fully local and never contacts the node to encrypt: it (1) generates a fresh 32-byte symmetric key, (2) AES-256-GCM-encrypts the payload under that key (optionally with AAD), (3) wraps the symmetric key to the discovered network public key via sealToNetworkKey, and (4) emits the InlineEnvelope with the wrapped key plus its hash. The raw symmetric key is returned only "for caller bookkeeping; do NOT persist."
ECIES wrap
Wrapping (backend.rs:wrap_to_public_key) is X25519 ECIES with AES-256-GCM as the AEAD: generate an ephemeral X25519 keypair, Diffie-Hellman against the recipient public key, key an AES-256-GCM cipher (ColumnEncryption::new(shared) — the same AEAD as at-rest encryption) with the shared secret, and emit ephemeral_pub(32B) || AES-256-GCM(plaintext). Unwrapping reverses this: read the peer's ephemeral key off the front, DH against the network secret, and AES-256-GCM-decrypt the remainder.
Key custody — LocalOneOfOneBackend
V1 ships exactly one KeyBackend, LocalOneOfOneBackend (backend.rs). generate() produces an X25519 StaticSecret, returns the public key, and seals the private key with a ColumnEncryption derived from b"tinycloud/encryption/network-seal" — the same at-rest mechanism used for other sensitive DB columns (in DStack mode this seal is rooted in TEE key management; see at-rest). The sealed private key lives in the encryption_network row. The trait exposes only generate, unwrap (sealed private key + wrapped key → raw symmetric key), and rewrap (symmetric key + receiver public key → re-sealed key); "higher-level code is responsible for never persisting the raw symmetric key," and the design note for future backends is that they "must avoid ever assembling the full network private key."
Node unwrap/rewrap (never plaintext)
The node never decrypts the payload. On a verified decrypt invocation, it loads the sealed private key, unwraps the encryptedSymmetricKey to the raw symmetric key, then immediately rewraps that key to the caller's per-request receiverPublicKey and returns only the rewrapped key (service.rs:570-573). The transient symmetric key is dropped. The client then DH-unwraps it with its ephemeral receiver secret and AES-256-GCM-decrypts the ciphertext locally (envelope.ts:decryptEnvelopeWithKey). So the node sees ciphertext-shaped key material on both sides and never the plaintext.
Relationships
Identified by a NetworkId URN (a Resource::Other form, not a space URI); decryption against it is a user-bound, capability-gated invocation checked against a capability chain rooted at the ownerDid; reuses the at-rest ColumnEncryption AEAD for both the ECIES wrap and sealing the private key at rest; published for discovery via the .well-known/encryption/network/<name> record (system-spaces); the reserved Threshold backend is the seed of threshold-decryption; one mechanism of the encryption overview.
Example
A network urn:tinycloud:encryption:did:pkh:eip155:1:0xf39F…2266:default is created (POST /encryption/networks, owner-authorized), running a one-of-one ceremony that lands the network Active with a published X25519 public key. A client encrypts a note: it generates a symmetric key, AES-256-GCM-encrypts the note, wraps the symmetric key to that public key, and stores the InlineEnvelope (v, networkId, alg = x25519-aes256gcm/v1, keyVersion = 1, encryptedSymmetricKey, hash, ciphertext) alongside the KV record. To read it back later, the client runs a decrypt invocation and the node rewraps the symmetric key to a fresh receiver key — the note's plaintext never leaves the client.
Status & drift
In-progress. LocalOneOfOneBackend (n=1, t=1), creation, descriptor, .well-known discovery, and the decrypt invocation path are implemented and tested in encryption_network/. The Dstack backend kind exists; the Threshold kind is reserved but "not implemented in v1" (see threshold-decryption). NetworkState enumerates Rotating but v1 documents transitions only as Pending → Generating → Active, and creation goes straight to Active. Key rotation/keyVersion bump beyond 1 is modeled in the data shapes but not exercised in v1. The decrypt invocation envelope is "intentionally a self-contained envelope, not the existing TinyCloud CACAO/UCAN invocation" — see user-bound-decrypt for the two verification paths. Code is canonical.
Sources
tinycloud-node:encryption_network/mod.rs(no node-side encrypt API),encryption_network/backend.rs(KeyBackend,LocalOneOfOneBackend,wrap_to_public_key/ECIES, seal-at-rest),encryption_network/types.rs(ALG_X25519_AES256GCM,InlineEnvelope,NetworkDescriptor,NetworkState,KeyBackendKind,Threshold),encryption_network/network_id.rs(NetworkIdURN parse),encryption_network/service.rs:154-245,570-573(create + unwrap/rewrap)js-sdk:packages/sdk-services/src/encryption/networkId.ts(URN grammar,NETWORK_NAME_RE),packages/sdk-services/src/encryption/envelope.ts(encryptToNetwork, local wrap/decrypt)