Identity & Keys
DID:key identifiers
Section titled “DID:key identifiers”Every xNet user has a Decentralized Identifier (DID) derived from their Ed25519 public key. The format follows the W3C DID:key method:
did:key:z6Mk...The public key is embedded directly in the DID, making it self-certifying — you can extract the public key and verify signatures without any external resolver or blockchain.
How it’s constructed
Section titled “How it’s constructed”- Take the 32-byte Ed25519 public key
- Prepend the multicodec prefix
0xed01(Ed25519 public key) - Encode with base58btc (multibase prefix
z) - Prefix with
did:key:
Generate an identity
Section titled “Generate an identity”import { generateIdentity, isValidDID } from '@xnetjs/identity'
const { identity, privateKey } = generateIdentity()// identity.did = "did:key:z6Mk..."// identity.publicKey = Uint8Array (32 bytes)// identity.created = Date.now()
isValidDID(identity.did) // trueParse a DID
Section titled “Parse a DID”import { parseDID } from '@xnetjs/identity'
const publicKey = parseDID('did:key:z6Mk...')// Returns the 32-byte Ed25519 public keyKey bundles
Section titled “Key bundles”Every xNet identity uses a HybridKeyBundle — classical keys (Ed25519 + X25519) bundled with post-quantum keys (ML-DSA-65) to protect against both current and future quantum attacks.
| Key | Algorithm | Size (bytes) | Purpose |
|---|---|---|---|
signingKey | Ed25519 | 32 | Sign changes, Yjs updates, UCAN tokens |
encryptionKey | X25519 | 32 | Classical key exchange |
pqSigningKey | ML-DSA-65 | 4,032 | Post-quantum signatures (Level 1/2) |
pqPublicKey | ML-DSA-65 | 1,952 | PQ verification key (cached) |
The DID is derived from the Ed25519 public key only — the DID format is unchanged and stays compact.
import { generateHybridKeyBundle, deriveHybridKeyBundle } from '@xnetjs/identity'
// Random generation — includes PQ keys by defaultconst bundle = generateHybridKeyBundle()// bundle.signingKey — Ed25519 private key (32 bytes)// bundle.encryptionKey — X25519 private key (32 bytes)// bundle.pqSigningKey — ML-DSA-65 private key (4,032 bytes)// bundle.pqPublicKey — ML-DSA-65 public key (1,952 bytes)// bundle.identity — { did, publicKey, created }
// Deterministic from a seed (same seed = same identity on every device)const bundle2 = deriveHybridKeyBundle(masterSeed)Storage
Section titled “Storage”Key bundles are serialized, encrypted with XChaCha20-Poly1305, and stored via PasskeyStorage. In production, the encryption key is derived from WebAuthn/passkey credentials.
import { BrowserPasskeyStorage } from '@xnetjs/identity'
const storage = new BrowserPasskeyStorage()const storedKey = await storage.store(bundle, credentialId)const recovered = await storage.retrieve(storedKey, credentialId)X25519 from Ed25519
Section titled “X25519 from Ed25519”The Ed25519 signing key can be birationally converted to an X25519 encryption key, so a single DID:key embeds both capabilities. xNet uses this conversion throughout the authorization system — the hub and other users can derive your X25519 public key directly from your DID, with no additional key exchange ceremony:
import { edwardsToMontgomeryPub } from '@noble/curves/ed25519'
// Derive the X25519 public key from an Ed25519 public keyconst x25519Pub = edwardsToMontgomeryPub(ed25519PublicKey)PQ Key Registry
Section titled “PQ Key Registry”The DID:key format stays Ed25519-based (compact, human-readable). To support Level 1/2 verification, the PQ Key Registry associates each DID with its ML-DSA public key via a PQKeyAttestation — a self-signed proof binding the two keys together.
import { MemoryPQKeyRegistry, createPQKeyAttestation } from '@xnetjs/identity'
// Create an attestation binding your DID to your PQ public keyconst attestation = await createPQKeyAttestation({ did: bundle.identity.did, pqPublicKey: bundle.pqPublicKey, signingKey: bundle.signingKey, // Ed25519 signs the attestation pqSigningKey: bundle.pqSigningKey // ML-DSA also signs for cross-attestation})
// Store in registryconst registry = new MemoryPQKeyRegistry()await registry.store(attestation)
// Look up during verificationconst pqPub = await registry.lookup(someUserDid)The hub maintains a public PQ Key Registry for all registered users. When another user encrypts data for you, they look up your ML-DSA public key here to wrap the content key at Level 1/2.
Security level in React
Section titled “Security level in React”Use XNetProvider to set the default signing level for your app, and useSecurity() to inspect or override it per-operation:
import { XNetProvider } from '@xnetjs/react'
// Configure default security level;<XNetProvider config={{ authorDID: identity.did, signingKey: bundle.signingKey, pqSigningKey: bundle.pqSigningKey, security: { level: 1, // 0=Fast, 1=Hybrid(default), 2=PQ-Only minVerificationLevel: 1, // reject signatures below this level verificationPolicy: 'strict' // both Ed25519 AND ML-DSA must verify } }}> <App /></XNetProvider>import { useSecurity } from '@xnetjs/react'
function HighSecurityAction() { const { level, setLevel, canSignAtLevel } = useSecurity()
return ( <button onClick={() => setLevel(2)} // elevate to PQ-only for this action disabled={!canSignAtLevel(2)} // false if PQ keys not available > Sign with maximum security </button> )}useSecurity() also exposes stats visible in the DevTools Security panel: current level, signature counts by level, and verification cache hit rate.
Key recovery
Section titled “Key recovery”Seed phrase
Section titled “Seed phrase”Your identity can be derived deterministically from a BIP-39 seed phrase. Back up the seed phrase and you can always restore your identity — and regain access to all your encrypted data — on any device:
import { deriveSeedPhrase, recoverFromSeedPhrase } from '@xnetjs/identity'
// Generate a new identity with a recoverable seedconst { mnemonic, bundle } = deriveSeedPhrase()// mnemonic: "correct horse battery staple ..."
// Recover on a new device — produces the same DID and key pairconst recoveredBundle = recoverFromSeedPhrase(mnemonic)The seed phrase generates the Ed25519 signing key, and the X25519 encryption key is derived from it via birational conversion — not independently. This ensures the DID, signing key, and encryption key are always consistent.
Social recovery
Section titled “Social recovery”As a fallback when the seed phrase is unavailable, xNet supports Shamir’s Secret Sharing. Split the seed across trusted contacts (e.g., 3-of-5 threshold) so any quorum can reconstruct your identity:
import { splitSeed, reconstructSeed } from '@xnetjs/identity'
const shares = splitSeed(mnemonic, { threshold: 3, total: 5 })// Distribute shares to trusted contacts
const recovered = reconstructSeed(anyThreeShares) // any 3 of 5 workEncrypted hub backup
Section titled “Encrypted hub backup”With opt-in hub backup, your key bundle is stored encrypted on a hub, unlocked by a passphrase or WebAuthn credential. The hub never sees the plaintext key:
import { HubKeyBackup } from '@xnetjs/identity'
const backup = new HubKeyBackup(hubUrl)await backup.store(bundle, { passphrase: 'my-secret-passphrase' })const restored = await backup.retrieve({ passphrase: 'my-secret-passphrase' })Multi-device access
Section titled “Multi-device access”When you sign in on a second device:
- The new device generates a device-specific key pair
- It derives the same primary key from your seed phrase
- Your primary key wraps content keys for each device
All devices share the same DID — collaborators don’t need to know which device you’re on.
Cryptographic primitives
Section titled “Cryptographic primitives”The @xnetjs/crypto package provides all the low-level operations:
Hashing (BLAKE3)
Section titled “Hashing (BLAKE3)”import { hash, hashHex } from '@xnetjs/crypto'
const digest = hash(data) // Uint8Arrayconst hex = hashHex(data) // hex stringconst b64 = hashBase64(data) // base64url stringconst sha = hash(data, 'sha256') // SHA-256 fallbackSigning (Ed25519)
Section titled “Signing (Ed25519)”import { generateSigningKeyPair, sign, verify } from '@xnetjs/crypto'
const { publicKey, privateKey } = generateSigningKeyPair()const signature = sign(message, privateKey)const valid = verify(message, signature, publicKey) // booleanEncryption (XChaCha20-Poly1305)
Section titled “Encryption (XChaCha20-Poly1305)”import { generateKey, encrypt, decrypt } from '@xnetjs/crypto'
const key = generateKey() // 32 bytesconst encrypted = encrypt(plaintext, key) // { nonce, ciphertext }const decrypted = decrypt(encrypted, key) // Uint8ArrayKey exchange (X25519 + HKDF)
Section titled “Key exchange (X25519 + HKDF)”import { generateKeyPair, deriveSharedSecret } from '@xnetjs/crypto'
const alice = generateKeyPair()const bob = generateKeyPair()const shared = deriveSharedSecret(alice.privateKey, bob.publicKey)// Both sides derive the same 32-byte shared secretUCAN tokens
Section titled “UCAN tokens”UCANs (User Controlled Authorization Networks) are self-signed capability tokens for decentralized authorization. They replace traditional API keys or OAuth tokens. UCAN tokens are signed with the hybrid signature at whatever level the signer’s SecurityContext specifies.
Structure
Section titled “Structure”interface UCANToken { iss: string // Issuer DID aud: string // Audience DID exp: number // Expiration (Unix seconds) att: UCANCapability[] // Capabilities granted prf: string[] // Proof chain (parent tokens) sig: Uint8Array // Ed25519 signature}
interface UCANCapability { with: string // Resource URI: 'xnet://doc/123' or '*' can: string // Action: 'read', 'write', or '*'}Create a token
Section titled “Create a token”import { createUCAN } from '@xnetjs/identity'
const token = createUCAN({ issuer: alice.did, issuerKey: alice.privateKey, audience: bob.did, capabilities: [ { with: 'xnet://doc/123', can: 'write' }, { with: 'xnet://doc/*', can: 'read' } ], expiration: Date.now() / 1000 + 3600, // 1 hour (default) proofs: [] // Parent tokens for delegation})Verify a token
Section titled “Verify a token”import { verifyUCAN, hasCapability } from '@xnetjs/identity'
const result = verifyUCAN(token)if (result.valid) { const canWrite = hasCapability(result.payload, 'xnet://doc/123', 'write')}Verification extracts the issuer’s public key from their DID (no external resolver), checks expiration, and verifies the Ed25519 signature.
Delegation chains
Section titled “Delegation chains”flowchart LR A["Alice\n(document owner)"] -->|"grants write\n(signs UCAN)"| B["Bob"] B -->|"delegates read\n(cites Alice's UCAN as proof)"| C["Carol"] C -->|"verifies chain"| D["Alice → Bob → Carol"]
UCANs support transitive delegation via the prf (proofs) field:
// Alice grants Bob write accessconst aliceToBob = createUCAN({ issuer: alice.did, issuerKey: alice.privateKey, audience: bob.did, capabilities: [{ with: 'xnet://doc/123', can: 'write' }]})
// Bob delegates read access to Carol, citing Alice's grant as proofconst bobToCarol = createUCAN({ issuer: bob.did, issuerKey: bob.privateKey, audience: carol.did, capabilities: [{ with: 'xnet://doc/123', can: 'read' }], proofs: [aliceToBob]})Integration with React
Section titled “Integration with React”Identity is provided to the app through XNetProvider:
import { XNetProvider } from '@xnetjs/react'import { XNetDevToolsProvider } from '@xnetjs/devtools';<XNetProvider config={{ authorDID: identity.did, signingKey: privateKey, identity: identity // ... }}> <XNetDevToolsProvider> <App /> </XNetDevToolsProvider></XNetProvider>The useIdentity hook exposes identity state to components:
import { useIdentity } from '@xnetjs/react'
function Profile() { const { identity, isAuthenticated, did } = useIdentity()
if (!isAuthenticated) return <LoginScreen /> return <p>Signed in as {did}</p>}Related
Section titled “Related”- Authorization — Roles, grants, and encryption-first access control
- Cryptography — Ed25519, X25519, and the encrypted envelope model
How identity flows through the stack
Section titled “How identity flows through the stack”flowchart TD A["XNetProvider\n(authorDID + signingKey)"] --> B["NodeStore"] A --> C["SyncManager"] B --> D["mutate.create() / update()"] D --> E["Ed25519 sign Change‹T›"] C --> F["BSM\n(Background Sync Manager)"] F --> G["signYjsUpdate()\nSignedYjsEnvelope"] F --> H["ClientID attestation\n(clientId → DID binding)"] G --> I["Broadcast to peers"] I --> J["Remote peer\nverify all signatures"]
XNetProvidercreates aNodeStorewithauthorDIDandsigningKey- Every
mutate.create()/mutate.update()call signs the change with Ed25519 SyncManagerreceives the signing credentials and passes them to the BSM- Yjs updates are signed as
SignedYjsEnvelopebefore broadcast - ClientID attestations bind the Yjs clientID to the DID
- Remote peers verify all signatures before applying changes