minca

01 — Identity

An account is 16 random bytes. Every key the account uses is derived from them, so the same 16 bytes give the same identity on any device and any origin. This part specifies the seed and its written form, every key derived from it, how keys become DIDs, how the root key delegates to the keys that actually sign (notes, in a JSON profile of UCAN 1.0), the two ways anything is sealed (envelopes), how the seed is stored locked on a device, and how a second device receives an account (pairing).

seed (16 bytes)
 ├─ HKDF "minca/p256-identity-key/v1" ─▶ root key (P-256, ECDSA) ─▶ account DID (did:key:zDn…)
 │                                         └─ signs notes ─▶ session / app keys ─▶ sign records
 ├─ HKDF "minca/p256-contact-key/v1"  ─▶ contact key (P-256, ECDH)
 │                                         ├─ HKDF "minca/p256-inbox-key/v1|<gen>"      ─▶ inbox key per gen (ECDH)
 │                                         └─ HKDF "minca/p256-inbox-sign-key/v1|<gen>" ─▶ inbox signing key per gen (ECDSA)
 ├─ HKDF "minca/local-key/v1"        ─▶ local key (AES-256): seals this device's storage; never handed out
 └─ HKDF "minca/account-key/v1"      ─▶ account key (32 bytes): what an app given the whole account holds
                                           ├─ HKDF "minca/p256-member-key/v1|<space>" ─▶ member key per space
                                           └─ HKDF "minca/account-registry/…", "minca/contacts/…" ─▶ account spaces

Conventions (MUST/SHOULD, encodings, canonical JSON) are those of the spec README. In this part:

  • HKDF(ikm, label, L) is HKDF-SHA256 (RFC 5869) with an empty salt (zero-length, which RFC 5869 treats as 32 zero bytes), info = the UTF-8 bytes of label, output length L bytes.
  • CID(bytes) is "b" followed by the RFC 4648 base32 encoding of SHA-256(bytes), lowercase, without padding — always 53 characters. (It is "CID-like", not a real multiformats CID; 02 — Records uses the same function.)
  • n is the order of the P-256 group: ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551.

1. The seed

An account seed is exactly 16 bytes (128 bits) from a cryptographically secure random source. It is the account: anything that holds the seed can act as the account, and nothing else can.

  • A new account MUST generate its seed with a CSPRNG (crypto.getRandomValues).
  • The seed MUST NOT be stored in the clear. It is stored only as wraps (§11), or held in memory while unlocked, or written down by the person as a recovery code (§2).
  • The seed never leaves the account home (the page or process that unlocked it) except as a recovery code or sealed in a pairing handover (§15). Apps receive delegations (§7), never the seed (06 — Nodes, sessions and apps).

Rationale: one secret, everything derived, means recovery is "type 26 characters" on any device, with no server and no backup of anything else.

Source: packages/core/src/identity/recovery-code.ts (generateSeed, RECOVERY_SEED_BYTES). Tests: packages/core/tests/recovery-code.test.ts, packages/core/tests/identity.test.ts.


2. Recovery codes

The recovery code is the seed written out. It is not a backup of the seed and does not unlock anything stored: it is the seed.

2.1 Alphabet

Crockford base32, 32 symbols, value = index:

0123456789ABCDEFGHJKMNPQRSTVWXYZ

(I, L, O, U are not symbols.)

2.2 Encoding

  1. Take the 16 seed bytes as a 128-bit big-endian bit string.
  2. Split it into 5-bit groups, most significant first. The last group has 3 bits; pad it on the right with two 0 bits. This gives 26 symbols.
  3. Group the symbols in fours, from the left, joined with -. The result is six groups of four and one of two: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XX (32 characters).

Encoders MUST emit uppercase, hyphen-grouped form as above.

2.3 Decoding

  1. Normalize: uppercase; remove every - and every whitespace character (\s); then substitute O→0, I→1, L→1, U→V.
  2. Validate: the result MUST be exactly 26 characters, each in the alphabet. Otherwise the code is invalid.
  3. Concatenate the 5-bit values and take the first 128 bits as the seed. The two trailing padding bits are ignored.

There is no checksum. Validation checks only length and alphabet; a mistyped symbol decodes to a different, valid seed and therefore a different DID. Because the two padding bits are ignored, the last symbol has four spellings that decode to the same seed (see the example).

Rationale: the code has to survive a password manager and a human copying it by hand; case, spacing and the O/0, I/1/L slips are forgiven. A caller that expects a particular account detects a wrong code by comparing the derived DID with the one it expected (session/auth.ts does this).

Planned: a check character. A typo today signs in to a new, empty account. The plan adds one Crockford check symbol, so a mistyped code is refused instead of opening a different account. The format is pre-release and may change; no migration is needed. Open: whether to reuse Crockford's mod-37 check symbol (which adds *~$=U to the alphabet) or a check computed over the 26 symbols in the existing alphabet, and whether to add a version marker at the same time. Replaces the "checksum" line in §16. Tracked in #20.

2.4 Example

seed (hex)00112233445566778899aabbccddeeff
recovery code008J-4CT4-ANK7-F24S-NAXW-SQFE-ZW
also decodes to the same seed008j 4ct4 ank7 f24s naxw sqfe zw, …-ZZ (padding bits differ)
recorded example (tests)K7N6-ERYP-68TZ-A7HN-VJW3-QWKN-CG → seed 99ea6763d63235f51e35dcb83bf27564

Source: packages/core/src/identity/recovery-code.ts. Tests: packages/core/tests/recovery-code.test.ts, packages/core/tests/identity.test.ts ("a known recovery code derives its recorded DID").


3. Deriving P-256 keys

Every signing or key-agreement key in Minca that is not freshly random is derived from bytes by one of two procedures.

3.1 ScalarFrom(okm)

Given 48 bytes okm:

d = (OS2IP(okm) mod (n − 1)) + 1          // OS2IP: big-endian unsigned integer

d is the private scalar, in [1, n−1]; the public key is Q = d·G. This is the FIPS 186-5 Appendix A.2.1 method (48 = 32 + 16 bytes, so the bias is negligible). The reference implementation calls p256.utils.randomSecretKey(okm) from @noble/curves, which computes exactly this.

3.2 P256KeyFrom(ikm) — the identity derivation

okm = HKDF(ikm, "minca/p256-identity-key/v1", 48)
d   = ScalarFrom(okm)

ikm MUST be at least 16 uniformly random bytes. This is what CryptoProvider.deriveKeyPairFromSeed does. It is used for:

Keyikm
Root key (the account identity)the 16-byte seed
Space invite key (03)HKDF(inviteSecret, "minca/space-invite/v1", 32)
Space read key (03)HKDF(rawSpaceKey, "minca/space-read/v1", 32)

The contact key and member keys (§10) use ScalarFrom directly, with their own labels, without the identity label.

Derivation is frozen. Changing the label, the length, or the reduction changes every account's DID. The recorded vectors in packages/core/tests/identity.test.ts MUST keep passing.

3.3 Example (root key)

seed00112233445566778899aabbccddeeff
okm (48 bytes)b1fc34eb5cbbdbabadec3403288c38f11ff530ad0e5e5561c1e8a36698232fdb11c563fc83da5893315f3e37cd3ed86d
d686fc0350a6b8c186f7b0b7856e67534000e51f22a79609256330344e73c2dee
Q, compressed02388118d5b41d2e75aa0732d0b00606b5c1c085aaaff729fbcfc0349aa42247e2
DIDdid:key:zDnaeUEY3vrNH2aBdwvsv4qvZpDJ2qHm3Z6UFkorqWJw3CmZT

Other recorded vectors: 16 zero bytes → did:key:zDnaenhB7GpWm1sDUA2mcNLjbZG85bD5uv8kXnX39YE9rGV3d; 16 0xff bytes → did:key:zDnaeWqjSG4BrSYadheJUx2CC9zxRM6MznQb3bMEW51WEfsxn.

Source: packages/core/src/identity/crypto-p256.ts, packages/core/src/identity/identity-manager.ts (fromSeed, fromRecoveryCode). Tests: packages/core/tests/identity.test.ts ("deriveKeyPairFromSeed", "derivation is frozen", "noble computes the same public point Web Crypto does").


4. DIDs (did:key)

Every key in Minca — root, session, app, invite, read — is named by a did:key over its P-256 public key.

did = "did:key:z" ‖ base58btc( 0x80 0x24 ‖ compressedPoint )
  • 0x80 0x24 is the unsigned-varint encoding of multicodec p256-pub (0x1200).
  • compressedPoint is the 33-byte SEC1 compressed point (0x02/0x03 ‖ x).
  • z is the multibase prefix for base58btc. P-256 DIDs therefore always begin did:key:zDn.

Encoders MUST use the compressed point. Decoders:

  1. MUST require the prefix did:key:z;
  2. base58btc-decode the rest;
  3. read the multicodec varint (bytes while the high bit is set, plus one);
  4. treat the remainder as the public key. The implementation accepts a compressed (33-byte) or uncompressed (65-byte, 0x04 ‖ x ‖ y) point and rejects bytes that are not a point on P-256.

Decoders SHOULD reject a multicodec other than 0x80 0x24.

Known defect: the reference decoder does not check the multicodec (packages/core/src/identity/did.ts, didToPublicKey). A non-P-256 key fails later, only because it does not import as a P-256 point. A delegation's iss and aud are checked already: a P-256 did:key in its one spelling (§7.1, isP256Did in packages/core/src/identity/ucan.ts). A fix will reject any other multicodec when decoding. Tracked in #20.

Planned: one spelling per key. Decoders will require the 0x80 0x24 prefix and exactly 33 compressed bytes, refusing the uncompressed form. Then a key has exactly one DID, and two DIDs never name the same key (which matters wherever DIDs are compared as strings: roots, createdBy, members). This fixes the known defect above.

Example: see §3.3; the decoded bytes are 802402388118d5b41d2e75aa0732d0b00606b5c1c085aaaff729fbcfc0349aa42247e2. The did:key specification's own P-256 example, did:key:zDnaerDaTF5BXEavCrfRZEk316dpbLsfPDZ3WJ5hRTPFU2169, round-trips.

Public keys elsewhere in Minca that are not DIDs (the contact key, member keys) are the 33-byte compressed point, base64url.

Source: packages/core/src/identity/did.ts, packages/core/src/identity/crypto-p256.ts (exportPublicKey, importPublicKey). Tests: packages/core/tests/identity.test.ts ("did:key", "reads the did:key specification's P-256 example").


5. Derivation labels

Every label under which bytes are derived anywhere in packages/core/src/. No two purposes share a label, but see the notes below the table. Parts other than this one own the use; this table is the registry.

Label (UTF-8)MechanismInputOutputUsed forDefined inPart
minca/p256-identity-key/v1HKDF, L=48 → ScalarFromseed; or any ikm in §3.2's tableP-256 scalarRoot key; second stage of invite and read keysidentity/crypto-p256.ts:801
minca/p256-contact-key/v1HKDF, L=48 → ScalarFromseedP-256 scalar (ECDH)Contact keyidentity/contact-key.ts:1701
minca/p256-member-key/v1|<spaceId>HKDF, L=48 → ScalarFromaccount keyP-256 scalar (ECDH)Member key for one spaceidentity/contact-key.ts:1801, 03
minca/p256-inbox-key/v1|<gen>HKDF, L=48 → ScalarFromcontact key's 32-byte private scalarP-256 scalar (ECDH)Inbox key (knocks are sealed to it)identity/contact-key.ts:1901, 07
minca/p256-inbox-sign-key/v1|<gen>HKDF, L=48 → ScalarFromcontact key's 32-byte private scalarP-256 scalar (ECDSA)Inbox signing key (signs the inbox, clears its mailbox)identity/contact-key.ts:2001, 07
minca/p256-name-key/v1|<name>@<domain>HKDF, L=48 → ScalarFromcontact key's 32-byte private scalarP-256 scalar (ECDSA)Name key: claims a username on a Minca name serviceidentity/contact-key.ts:21docs
minca/inbox-topic/v1|<signKey>SHA-256, base64url—mailbox topicAn inbox's mailbox topic on a relay: a hash of its signing keyinbox/inbox.ts:18907
minca/local-key/v1HKDF, L=32seedAES-256-GCM keyLocal key: this device's storage (§11.1)identity/account-vault.ts:2301, 05
minca/account-key/v1HKDF, L=32seed32 bytesAccount key: root of member keys and account spaces (§11.1)identity/account-vault.ts:2401, 06
minca/pairing-room/v1CID of prefix ‖ secret (no separator)16-byte pairing secretroom idPairing roomidentity/channel.ts:4001
minca/pairing-key/v1|<side>|<O>|<J>HKDF, L=32pairing secret ‖ ECDH shared xAES-256-GCM keyPairing messages sent by <side> (offer or join)identity/channel.ts:4101
minca/pairing-code/v1|<O>|<J>HKDF, L=4 → uint32 mod 10⁶pairing secret ‖ ECDH shared x6-digit codeCode both screens showidentity/pairing.ts:1801
minca/code-check/v1|<O>|<J>HKDF, L=4 → uint32 mod 10⁶connect-code secret ‖ ECDH shared x6-digit checkCheck the home shows and the person gives the programsession/connect-code.ts:7106
minca/code-room/v1CID of prefix ‖ secret (no separator)16-byte connect-code secretroom idRoom for connecting by codeidentity/channel.ts:4706
minca/code-key/v1|<side>|<O>|<J>HKDF, L=32connect-code secret ‖ ECDH shared xAES-256-GCM keyConnect-by-code messages sent by <side>identity/channel.ts:4806
minca/space-invite/v1HKDF, L=32 → P256KeyFrom32-byte invite secretP-256 keyInvite keyspace/space-access.ts:2703
minca/invite-link/v1|secretHKDF, L=3216-byte short link secret32-byte invite secretA short link's invite secret, which the invite key is derived fromspace/invite-link.ts:18503
minca/invite-link/v1|sealHKDF, L=3216-byte short link secretAES-256-GCM keyThe sealed invite a short link namesspace/invite-link.ts:17403
minca/invite-topic/v1|<pin>SHA-256, base64url—mailbox topicA short link's mailbox topic: a hash of its pin, which no key hashes tospace/invite-link.ts:15603
minca/space-read/v1HKDF, L=32 → P256KeyFromspace key KP-256 keyRead key of a private spacespace/space-access.ts:2603
minca/space-seal/v1HKDF, L=32space key KAES-256-GCM keyEvery envelope under a space keyprivacy/space-encryption.ts:1503
minca/space-key-id/v1HKDF, L=16, base64urlspace key Kkey id (22 characters)A space key's idprivacy/space-encryption.ts:1603
minca/account-registry/nonce/v1HKDF, L=32, first 12 bytes, base64urlaccount keyspace nonceAccount registry spacespace/account-registry.ts:85,12703
minca/account-registry/key/v1HKDF, L=32account keyspace key KAccount registry spacespace/account-registry.ts:85,13003
minca/contacts/nonce/v1as aboveaccount keyspace nonceContacts spacespace/account-registry.ts:98,12703
minca/contacts/key/v1as aboveaccount keyspace key KContacts spacespace/account-registry.ts:98,13003
minca/carry/nonce/v1|<did>|<gen>as aboveaccount keyspace nonceA carrier's carry spacespace/account-registry.ts:114,12703, 06
minca/carry/key/v1|<did>|<gen>as aboveaccount keyspace key KA carrier's carry spacespace/account-registry.ts:114,13003, 06
minca/topic-tags/v1HKDF → HMAC-SHA256 keyspace key K (private) or the bytes of minca/public-topics/v1|<spaceId> (public)HMAC keyTopic tagsrecords/topics.ts:86,8702
minca/one-per/v1HKDF → HMAC-SHA256 keythe space's first key K₀ (private) or the bytes of minca/public-one-per/v1|<spaceId> (public)HMAC keyonePer record keysrecords/rules.ts:78,8202
minca/record-owner/v1|<root>SHA-256, first 16 bytes, base32 lowercase—owner part (26 characters)The owner part of an owned record keyrecords/key.ts:5402
minca-room/v1|<spaceId>SHA-256, first 20 bytes, base32 lowercase—relay room nameA space's room on a relaynode/space-peers.ts:6804
minca/rendezvous-topic/v1|<room>SHA-256, base64url—topic (43 characters)A room's topic on a relaynetwork/rendezvous.ts:13004
minca/rendezvous-key/v1|<room>SHA-256—AES-256-GCM keyWhat is said in a room, on a relaynetwork/rendezvous.ts:13104

Labels of sealed things: every envelope and every HPKE seal (§9) names one, with the fields of its bound. In both tables, docs marks a label used by a convention built on the protocol (packages/core/docs/), not by it; CLI one only the CLI's own node reads:

LabelPrimitiveboundDefined inPart
minca/record-body/v1envelopespace, collection, key, keyIdprivacy/space-encryption.ts:2003
minca/earlier-keys/v1envelopespace, keyIdprivacy/space-encryption.ts:2203
minca/membership/v1envelopespaceprivacy/space-encryption.ts:2403, 04
minca/space-key-box/v1HPKEspace, keyId, tospace/space-access.ts:13303
minca/vault-wrap/v1envelopedid, id, kind; and salt, iterations (passphrase)identity/account-vault.ts:2601
minca/at-rest/v1envelopeentrystorage/encrypted-adapter.ts:1605
minca/pairing/v1envelopetype, seqidentity/channel.ts:4201
minca/code/v1envelopetype, seqidentity/channel.ts:4906
minca/rendezvous/v1envelopetopicnetwork/rendezvous.ts:10404
minca/rendezvous-signal/v1HPKEtopic, from, to, boxnetwork/rendezvous.ts:10504
minca/mail/v1HPKEtoinbox/inbox.ts:6407, 03
minca/direct-text/v1envelopespace, from, toprivacy/direct.ts:37docs
minca/direct-box/v1HPKEspace, from, to, readerprivacy/direct.ts:39docs
minca/writer-key/v1envelopedid, slotpackages/cli/src/writer-keys.ts:27CLI

A short link's sealed invite (03 §7.7) is AES-256-GCM under its own seal key with no envelope or bound: one key seals one invite, and the link names it by its hash, so it can't be taken for anything else.

Strings that separate signed messages rather than derive keys, listed so new labels do not collide with them:

StringKindDefined inPart
minca/space-invite/v1|<spaceId>|<did>Message signed by an invite keyspace/space-access.ts:13703
{"audience":…,"proof":"minca/connect-key/v1",…}Canonical JSON signed by a key a connect request namessession/connect.ts:284 (keyStatement)06
minca/inbox-purge/v1|<topic>|<nonce>|<ids>Signed by an inbox signing key, checked by a relayinbox/inbox.ts:197, packages/relay/relay.mjs (purge)07
minca-peer/v3|client|…, minca-peer/v3|server|…Signed peer-auth messagesnetwork/peer-auth.ts:198,20004
minca-mesh/v1|…Signed mesh proofnetwork/peer-auth.ts:31604
minca/introduction/v1|…Signed signal passed on by peersnetwork/introductions.ts:6104
minca/rendezvous-sig/v1|…Signed rendezvous messagenetwork/rendezvous.ts:12504
minca-host/v1\n…, minca-host-status/v1\n…Signed host requests and statusessession/hosting.ts:78,34008

Notes:

  • minca/p256-identity-key/v1 is applied to more than one kind of input: the seed, and (as a second stage) invite and read secrets that were first expanded under their own labels. The first-stage label is what separates them.
  • minca/space-invite/v1 is both an HKDF label and the prefix of a signed message. The two uses are in different primitives and do not collide, but a new use MUST NOT reuse either string.
  • New labels SHOULD follow the minca/<purpose>/v<N> form. A label of a sealed thing names exactly one use, and no derivation label is reused as one.

6. Keys and signers

KeyHow madeLifetimeSigns
Root keyP256KeyFrom(seed)The account'sNotes only (§7)
Session keyA lasting P-256 key claimed under a lock, else fresh (02 §4.10)While the device keeps it, else one node runRecords, peer-auth messages
App keyFresh random P-256 key pair kept by an app or other programUntil the program forgets itRecords, under a grant
Contact keyScalarFrom(HKDF(seed, contact label, 48))The account'sNothing; ECDH only (§10)
Member keyScalarFrom(HKDF(accountKey, member label, 48))The account'sNothing; ECDH only (§10)
Inbox keyScalarFrom(HKDF(contactKey, inbox label, 48))Until the inbox is replaced (a new gen)Nothing; ECDH only (§10)
Inbox signing keyScalarFrom(HKDF(contactKey, inbox sign label, 48))Until the inbox is replaced (a new gen)The inbox code, purge challenges (07)

The root key signs exactly one kind of thing: a note (§7) delegating to another key. It MUST NOT be used to sign records directly in normal operation. (A record with no proof is judged as written by its author key itself — see 02 — Records.)

6.1 Root signers

Not protocol. Where the root key lives, and the interface a node asks for notes through, are the library's: see Root signers and notes.

6.2 The session delegation

A node signs records with a session key (§6) under a note from the root (§7) made out to it: aud is the session DID, sub the account's DID, and the node's proof is that token. Every record the session key signs carries the proof as its proof (02 — Records). Peers check only that the note verifies and grants what each record needs (§7.5, §8); which command it names, how long it lasts and when it is renewed are the node's choice (Root signers and notes, 06).

Source: packages/core/src/node/node.ts (delegate). Tests: packages/core/tests/node.test.ts ("records outlive the session that wrote them").


7. Delegations (UCAN 1.0, JSON profile)

A delegation is a signed token by which an issuer key passes some of what it may do to an audience key. A note is the one kind peers accept: a delegation from the account's root key to another key (§7.5). Minca writes them in its own JSON profile of UCAN 1.0: UCAN 1.0's fields (iss, aud, sub, cmd, pol, nonce, meta, exp, nbf), its command paths and its policy language, in Minca's canonical JSON (02 §1) and signed as Minca signs everything else, instead of DAG-CBOR with a varsig header. UCAN's chains are not used: a note is always one link.

It is not byte-compatible with other UCAN tools: a Minca token can't be handed to a UCAN library, nor a UCAN token to a Minca peer. That is deliberate. Every other signed thing in Minca is canonical JSON with a plain ES256 signature, and DAG-CBOR and varsig would be a second encoding, a second hash and a dependency for one kind of token. The semantics carry over whole, so moving to the CBOR form later is a change of encoding, not of meaning.

Minca uses delegations, not UCAN invocations: a version, a knock or a peer proof is what the key does, and the peer that checks it fills in the arguments a policy reads (§8). Minca's revocation is its own, a record that names a key (03 §6.4); UCAN's revocation spec is not used.

7.1 The token

A token is a JSON object with exactly three members:

token        = { "payload": <payload>, "sig": <signature>, "type": "minca/ucan/dlg@1" }
signed bytes = UTF-8( canonicalJSON( { "payload": <payload>, "type": "minca/ucan/dlg@1" } ) )
  • type names the profile and the kind of token, as UCAN's envelope tag does. A peer MUST refuse any other value.
  • sig is ECDSA P-256 / SHA-256 by the issuer's key over the signed bytes: 64 bytes r ‖ s (IEEE P1363), base64url without padding, so exactly 86 characters. s MUST be in the low-S form, s ≤ n/2: an issuer produces only that form, and a verifier MUST refuse a signature with s > n/2. Its base64url MUST be the one spelling of its bytes (no padding, nothing outside the alphabet, zero trailing bits).

Payload. Exactly these members; a verifier MUST refuse any other.

FieldTypeRequiredMeaning
issstringyesIssuer: a P-256 did:key (§4) in its one spelling (multicodec p256-pub, 33-byte key). Its key signs it.
audstringyesAudience: who it is made out to. For a note, a P-256 did:key (§7.5)
substringyesSubject: what it is about. The account (its DID, the issuer's), or one space, space:<id> (§7.2)
cmdstringyesThe command it covers, and every command under it (§7.3)
polarrayyesPolicy: conditions on the arguments of what it is used for (§7.4). [] for none
noncestringyes1–64 base64url characters. Issuers use 12 random bytes. Makes each token unique; not otherwise checked
metaobjectnoSigned, and read by no peer: a peer MUST NOT judge anything differently for what meta holds
expinteger, Unix secondsnoNot valid at or after this time (§7.6). A note has one
nbfinteger, Unix secondsnoNot valid before this time (§7.6). A note has one

exp and nbf are integers from 0 to 2⁵³ − 1. exp is never null: a token time doesn't end leaves it out. sub is never null: UCAN's powerline is not used (§7.2). A token whose canonical form is longer than 16 KiB is refused.

The token's id is CID(UTF-8(canonicalJSON(token))) (the conventions above, 02 §2): signature included. Since the token is read as JSON and re-canonicalized, and its signature has one form, a token has exactly one id.

Rationale: a malleable signature gives a second token that verifies under a different id, and anything that names or counts tokens by id (a cache) would see two where there is one. Refusing high-S closes it at the source.

7.2 Subjects

subMeaning
the root's DIDThe account: everything it may do, in every space and as a whole (a knock)
space:<id>One space of the account's. <id> is 1–128 of [A-Za-z0-9._~-]

A DID subject MUST be the note's issuer, the account (§7.5): UCAN 1.0's own rule that the subject is whoever began the chain, for a chain of one. A note for every space is a note about the account. UCAN's null subject (a powerline) adds nothing when every note is from the account it is about, so it is refused, keeping one spelling for each meaning.

7.3 Commands

A command is /, or one or more segments each written / and then one or more of a–z, 0–9, ., _ and - (ASCII only), with no trailing /, at most 256 characters: UCAN 1.0's form, narrowed to ASCII so that no two spellings fold to the same command. As a pattern: ^/$|^(/[a-z0-9._-]+)+$. A command covers itself and every command under it, by whole segments: /record covers /record/write but not /recordings, and / covers everything.

CommandWhat it permits
/Everything below
/recordEvery /record/… command: records, and nothing more
/record/writeWrite records (every version in a space needs it)
/record/readReading, for an app given read access, which writes nothing; a peer checks it only on an invitation that shares a space to view (03 §7.4)
/collection/defineDefine, change or remove a space's collections (sys.collection, 02 §6)
/access/changeEvery other change to a space's access history: role definitions, assignments, removals, invites, revocations, keys, relays, keepers, forks
/account/writeWrite in the account's own spaces, open its inbox and knock (07)

What each command permits in a space is specified in 02 — Records, 03 — Spaces and 06. Which commands a version needs, with which arguments, is §8.

7.4 Policies

A policy is UCAN 1.0's policy language (UCAN Delegation, "Policy"), evaluated over canonical JSON values: a list of statements that MUST all hold for the arguments of what the token is used for (§8).

StatementHolds when
["==", sel, value], ["!=", sel, value]The selected value is (is not) deeply equal to value, any JSON value
["<", sel, n], ["<=", …], [">", …], [">=", …]Both are numbers, and compare so; n MUST be a number
["like", sel, pattern]The selected value is a string matching the glob pattern
["not", stmt]stmt does not hold
["and", [stmt, …]], ["or", [stmt, …]]Every (some) stmt holds. and of none holds; so does or of none
["all", sel, stmt], ["any", sel, stmt]The selected value is an array, or an object (its values), and stmt holds for every (some) element. all of none holds; any of none doesn't

Selectors are UCAN 1.0's: . (the arguments), .name (a name of [A-Za-z_][A-Za-z0-9_]*), .["any name"] (a JSON string), [i] and [-i] (an array element, from the end when negative), [a:b], [a:], [:b] (an array slice, negative bounds from the end), [] (an array's elements, or an object's values; what follows applies to each, and fails if it fails for any), and ? after a segment (null where it would fail; repeated ? is one). A selector starts with .; .., a trailing . and anything else make it malformed. A missing member of an object is null; selecting further into that null fails.

Globs: * matches any run of characters, including none; \* is a literal *; every other character, a backslash before anything but * included, is itself.

When a selector fails (it finds nothing and has no ?), the statement it is in has no data. No data never holds: not keeps it no data (so a missing field can't turn not into a pass), and is false if any part is false and otherwise no data if any part is, or is true if any part is true and otherwise no data if any part is, and a policy holds only if every statement holds. != is not ==. A comparison of the wrong types is false, not no data.

A missing member is not a failure. It reads as null, as in UCAN, so a negated statement about it holds: ["!=", ".role", "admin"] and ["not", ["==", ".role", "admin"]] both hold for arguments with no role. Only selecting further into it fails. Where an argument may be absent, a policy that means "only these" says what is allowed, with == or an or of == statements, rather than what isn't.

A policy that is not well formed (an unknown operator, the wrong number of arguments, a malformed selector, a non-number in an inequality, a non-string pattern, nested more than 32 deep or holding more than 256 statements) makes its token malformed: refused (§7.1).

Peers MUST evaluate a note's policy wherever they check the note (§8). The reference passes UCAN's published policy fixtures.

The UCAN fixtures leave some corners open; this section fixes them: any over an empty collection is false, not keeps no data, and slices apply to arrays only. The fixture file's one malformed case is corrected in the copy the tests use (packages/core/tests/vectors/ucan-policy.json).

7.5 Notes

What a key does carries its proof: one note, a token issued by the account's root key to that key. A record carries it in proof (02 §3.1); a knock (07) and a peer's proof (04) carry it the same way. It is a single JSON object, never a list.

Given a proof, and a time at or none (§7.6), a verifier MUST check:

  1. It is one token, a JSON object in the exact form of §7.1, signed by its iss (low S), within its time bounds at at (§7.6). Anything else, a list of tokens (a chain) included, is refused.
  2. It is a note: aud is a P-256 did:key, and exp and nbf are present.
  3. Its sub, when it is a DID, equals its iss.

The note is then for its aud, and from its iss: the root, the account it acts for.

A note grants a requirement (a command, a space or the account as a whole, and arguments; §8) iff its cmd covers the required command; its sub is the account, or space:<S> where the requirement is in space S; and its policy holds for the arguments.

No key below the root can extend a note. A token a session or app key signs is a note from that key, about that key's own DID: it names no account but the key itself, and acts for no one else.

Rationale: one link means a key can't mint keys. A stolen session or app key is like a stolen session token: usable, within its note, until it is revoked, but it can't pass its power on to keys the account never saw, so revoking it (03 §6.4) takes everything it can do, with the one cutoff the revoke names. With chains, a revoke would also have to reach every key below the revoked one, and peers would have to agree on what of theirs counted. A key that needs a narrower key of its own (an app's helper, a key per tab) asks the account for a second note.

7.6 Time

Which time step 1 uses depends on what the proof is relied on for:

  • A record's proof: no time at all. Time bounds are not checked. Whether a record stands is something every peer must agree on whenever it looks, so no one's clock, and no time the writer claims, may decide it (02 — Records §9.3). A record stays valid after its note runs out. What ends a key's writes is a revoke of the key, with a cutoff at what it signed (03 — Spaces §6.4).
  • Anything live (a knock, a live message, a peer's proof when it connects, an app checking its grant): the checking node's own clock, or, for a knock, the time it says it was signed, within the knock's 14 days (07 §6). The note MUST have nbf ≤ at < exp at that at. These are one node's decisions in the moment, not verdicts peers share, so a note that has run out stops working there on time, with no revoke needed.

So a leaked key's live use ends when its note runs out, except knocks, which it can date back into the note's life for 14 days after; and its records end when its key is revoked. Signing out writes a revoke of the session key in every space the node writes in, its carry spaces, the contacts space and the account registry, with a cutoff at what it wrote, and a device that kept the key forgets it (revokeSelf); the revokes reach other peers as that device next syncs. Open: removing a device from another one, which needs the list of the account's session keys (#165).

7.7 Issuing

An issuer produces tokens of the form in §7.1, low S only; what a verifier checks is §7.5–§7.6. The reference issuer's defaults (an hour, nbf set back by five minutes for clocks that disagree, the terms a home gives an app) are the library's: see Root signers and notes.

7.8 Example

Issued by the root key of §3.3 to the did:key spec's example key: records only, in collection app.note, with fixed times and nonce. The signed bytes:

{"payload":{"aud":"did:key:zDnaerDaTF5BXEavCrfRZEk316dpbLsfPDZ3WJ5hRTPFU2169","cmd":"/record","exp":1790000000,"iss":"did:key:zDnaeUEY3vrNH2aBdwvsv4qvZpDJ2qHm3Z6UFkorqWJw3CmZT","nbf":1789996100,"nonce":"AAECAwQFBgcICQoL","pol":[["==",".collection","app.note"]],"sub":"did:key:zDnaeUEY3vrNH2aBdwvsv4qvZpDJ2qHm3Z6UFkorqWJw3CmZT"},"type":"minca/ucan/dlg@1"}

One token over them (ECDSA signatures are randomized, so sig differs every time it is issued; this one is low-S):

{"payload":{"aud":"did:key:zDnaerDaTF5BXEavCrfRZEk316dpbLsfPDZ3WJ5hRTPFU2169","cmd":"/record","exp":1790000000,"iss":"did:key:zDnaeUEY3vrNH2aBdwvsv4qvZpDJ2qHm3Z6UFkorqWJw3CmZT","nbf":1789996100,"nonce":"AAECAwQFBgcICQoL","pol":[["==",".collection","app.note"]],"sub":"did:key:zDnaeUEY3vrNH2aBdwvsv4qvZpDJ2qHm3Z6UFkorqWJw3CmZT"},"sig":"HBKED5Qimk7X5F0Xd79Gcm4F64GdtYbFqyt7B-kO-ZEl8e2gD3mW07VQaVYfnngREQ5FmzHLA8tCZckpLo7QDg","type":"minca/ucan/dlg@1"}

Its id: bcmmtwj62gmokzcmv4h7devlpn2fgrkwof3gj6xv3lkpefcn7x6wa. The same token with s replaced by n − s verifies under Web Crypto, and is refused (§7.1).

Source: packages/core/src/identity/ucan.ts (issueDelegation, verifyDelegation, isCommand, verifyNote, noteCovers), packages/core/src/identity/policy.ts (evaluatePolicy, policyProblem), packages/core/src/validation/capability-gate.ts. Tests: packages/core/tests/ucan.test.ts ("the example of 01 §7.8", "one token, one id: the high-S form of a signature is refused", "refuses anything but its exact shape", "commands cover what is under them", "a chain is refused, however sound its links", "a subject naming an account is only ever its issuer", "what a note grants"), packages/core/tests/policy.test.ts (the UCAN fixtures), packages/core/tests/validation.test.ts ("a note is one link", "a note's policy is checked against the version"), packages/core/tests/space-access.test.ts ("a key cannot pass its note on"), packages/core/tests/attacks.test.ts.


8. What a note lets a key do

A key may do what its note grants (§7.5), judged against what each thing it does requires: a command, where (a space S, or the account as a whole), and the arguments the note's policy reads. The protocol never asks what kind of thing holds a key: a person's session, an app, any other program and a borrowed device are told apart only by what their notes grant. A note for / about the whole account acts as the account itself: that key is one of its devices. Any narrower note is an app's, whatever holds it.

A version in space S…requires (in S)with arguments
any record/record/write{ "space": S, "collection": <its collection> }
and in sys.collection/collection/define{ "space": S, "collection": C }, C the collection defined: its key after collection:
and in any other collection of the access history (03 — Spaces)/access/change{ "space": S, "collection": <its collection> }
and about the account itself (below)/account/write, for the account as a whole{}

A version is about the account itself when it is in one of the account's own spaces (its registry, its contacts space, its carry spaces; 06), or, in any space, when it is:

  • a sys.assign or sys.remove naming the account that signs it: taking or giving up a role, joining by invite, leaving;
  • a sys.stepdown;
  • a sys.memberkey or sys.profile: what others seal to the account, and what they see of it;
  • a sys.revoke of a key other than its own author: another of the account's keys.

So is a knock (07 — Inbox). Each requires /account/write for the account as a whole, with arguments {}: only a note about the account grants it, and a policy that limits it to some spaces does not hold there.

space is the space's id, without space:. S is the space the version is judged in, never one it claims. The arguments are exactly these: a policy that reads anything else finds null (a missing member) or nothing.

  • A version with no proof was signed by its author key itself, which may do anything (§6).
  • A peer MUST ignore a change to the access history whose proof does not grant what it requires: it is not an event in the history, whatever the writer's roles (03 — Spaces). Any other version about the account itself whose proof lacks /account/write for the account as a whole MUST NOT count. Before v1 this applies to what is already stored: a member key, profile, joining or leaving an app wrote under a narrower note stops counting, and a device of the account publishes the account's own again when it next opens the space.

A person's own sessions, and apps granted write, get / about the account. A key the account trusts less gets /record (a request for records only, 06 §2.4): it writes records, and proposes anything more for someone who may to apply (blueprints and guides). A note for one space is about that space (sub: "space:<id>"); a note for several is about the account, with a policy naming them: [["or", [["==", ".space", "<id>"], …]]]. Policies on .collection narrow a note to some collections; reading stays a matter of keys (03 — Spaces), which a note doesn't narrow. An action about the account as a whole (a knock, the account's own spaces) carries no space argument, so .space reads as null there: a policy that only excludes spaces (["not", ["==", ".space", "<id>"]]) holds for it. To keep a note to some spaces, name them, as above.

A key whose note grants /access/change in a space with no policy on .collection changes there what its account may do to others: roles, members, invites, the space's key, relays and keepers, and a successor (sys.succeed, 03 §2.5, §2.7). Its account's own place it leaves alone unless the note is about the whole account: an app given a space never makes the account leave or step down, never gives up its roles, never puts a key of its own where others seal to the account, and never revokes the account's other keys. A home that gives an app the access command, and wants less, names the collections it may change.

Rationale: one rule, not a command per act. Whether a key may act as the account is what a note about the whole account already says, so the same check guards the account's own spaces, its inbox and its place in every space, and a home tells a device from an app by the note it gives.

A note carries no fact a peer reads. A peer MUST NOT judge a version, a live message or a knock differently for anything in meta: what the key may do is what the proof grants.

Rationale: earlier, the protocol marked one kind of program with a fact, first refusing changes from a note that carried it and later only showing it as a label. Either way it tied the protocol to one kind of key. Commands and policies say what matters in what UCAN already has, and serve any key the account trusts less, a person's too.

Rationale for one command and a policy, not a set of delegations: every note a home gives names one command, across one space, several or all of them. One subject covers one space or all; a policy on .space covers several. So one token per version, and one requirement checked against it, keep everything notes granted before.

Source: packages/core/src/identity/abilities.ts (writeRequirement, accessRequirement, ACCOUNT_REQUIREMENT, actsForAccount, noteGrants, noteTerms), packages/core/src/node/space-history.ts (aboutItself, buildEvent), packages/core/src/node/space-standing.ts (judgeStanding), packages/core/src/node/space-writing.ts (write), packages/core/src/inbox/inbox.ts (openKnock), packages/core/src/session/connect.ts (grantTerms). Tests: packages/core/tests/records-only.test.ts ("what counts is what the note grants", "a note's policy is checked by every peer", "a definition signed by hand under a records-only note is ignored by every peer", "taking someone out of the space, under a records-only note, is ignored too", "an app given a space never changes the account's own place there: every peer ignores it", "the same change under a note about the whole account counts: that key is one of the account’s devices", "in the account's own spaces, a version under a records-only note does not stand"), packages/core/tests/connect.test.ts ("it lists them and opens requests sent to the account, and sees no other space"), packages/core/tests/inbox.test.ts ("needs a note that grants /account/write: …"), packages/core/tests/contacts.test.ts ("a knock left in a space stands only from a note that may write for the whole account…").


9. Envelopes

Everything Minca encrypts is sealed one of two ways: under a symmetric key, as an envelope (§9.1), or to a P-256 public key, with HPKE (§9.2). Each use names a label and a bound: the label says what the sealed thing is, the bound says where it belongs. Both are authenticated, so a sealed thing opens only for the use and the place it was sealed for: moved to another record, another space, another account or another slot, or presented as something else, it does not open.

  • A label is a constant string minca/<purpose>/v1, listed in §5 with its bound's fields.
  • A bound is a flat JSON object whose values are strings or numbers. Its bytes are its canonical JSON (02 — Records).
  • The context of a label and a bound is UTF-8(label) ‖ 0x00 ‖ UTF-8(canonical(bound)). A label never contains 0x00, so no two (label, bound) pairs share a context.

Where a sealed thing travels as text, it is base64url of its bytes.

9.1 The envelope

Under a 32-byte AES-256-GCM key k:

nonce    = 12 random bytes, fresh for every envelope
aad      = 0x01 ‖ context(label, bound)
envelope = 0x01 ‖ nonce ‖ AES-256-GCM(k, nonce, plaintext, aad)     // ciphertext ‖ 16-byte tag
OffsetBytesField
01version, 0x01
112nonce
13n+16ciphertext and tag

A reader MUST refuse (treat as not openable) an envelope whose first byte is not 0x01, one shorter than 29 bytes, and one that does not authenticate under the key, label and bound its use names. It MUST NOT fall back to reading the bytes some other way.

Example (packages/core/tests/envelope.test.ts, "opens the fixed vector"):

key000102…1f
labelminca/record-body/v1
bound{"space":"bexample","collection":"app.note","key":"k1","keyId":"bgk-m-Kq-gEGgJCOOEYNYw"}
aad0x01 ‖ minca/record-body/v1 ‖ 0x00 ‖ {"collection":"app.note","key":"k1","keyId":"bgk-m-Kq-gEGgJCOOEYNYw","space":"bexample"}
noncea0a1…ab
plaintext{"body":{"text":"hi"}}
envelope01a0a1a2a3a4a5a6a7a8a9aaab9d3a1e4221b220851947f3b67f0ee2e452c43032efca1438efe7c1526e9fe84d7b4aafc5653a

9.2 Sealing to a key (HPKE)

To a P-256 public key (a contact, member or inbox key, §10), with HPKE (RFC 9180) in base mode, single-shot:

ParameterValue
mode0x00 (base)
KEM0x0010, DHKEM(P-256, HKDF-SHA256)
KDF0x0001, HKDF-SHA256
AEAD0x0002, AES-256-GCM
infocontext(label, bound)
aadempty
enc, ct = SealBase(pkR, info = context(label, bound), aad = "", plaintext)
sealed  = 0x01 ‖ enc ‖ ct          // enc: the 65-byte uncompressed ephemeral point; ct includes the 16-byte tag

A reader MUST refuse a sealed value whose first byte is not 0x01, one shorter than 82 bytes, and one that does not open (OpenBase fails) for the recipient key, label and bound its use names. A sealed {"invite":"x"} is 1 + 65 + 14 + 16 = 96 bytes.

The implementation is checked against the CFRG test vectors for this suite, and for the same suite with AES-128-GCM (RFC 9180 Appendix A.3), in packages/core/tests/vectors/hpke-p256.json.

Rationale: one symmetric format and one public-key format, each with a version byte, so another implementation has two things to get right rather than one per use. HPKE puts the recipient's key and the ephemeral key into the shared secret, and is specified and tested elsewhere.

9.3 Every use

UseSealedKeyLabelboundWhere
A record body in a private spaceenvelopethe space key's seal keyminca/record-body/v1space, collection, key (the record's), keyId03 §8.3
Earlier keys in sys.keyenvelopethe new space key's seal keyminca/earlier-keys/v1space, keyId (the new key's)03 §9.3
A reader's note, proving it may readenvelopethe seal key behind the read key it provesminca/membership/v1space04 §6.3
A space key boxed to a memberHPKEthe recipient's member keyminca/space-key-box/v1space, keyId, to (account DID)03 §9.4
A knockHPKEan inbox key, or the contact keyminca/mail/v1to (the key it is sealed to)07 §6
A wrap of the seedenvelopea device key, or a passphrase's PBKDF2 keyminca/vault-wrap/v1did, id, kind; and salt, iterations (passphrase)§11.3
A registry value at restenvelopethe local keyminca/at-rest/v1entry (the storage key)05 §15
A pairing messageenvelopethe pairing key of the direction it travelsminca/pairing/v1type, seq§15.2
A connect-by-code messageenvelopethe code key of the direction it travelsminca/code/v1type, seq06 §3

Conventions built on the protocol use the same two primitives: direct messages (minca/direct-text/v1, minca/direct-box/v1) and the CLI's writer keys on disk (minca/writer-key/v1); see packages/core/docs/.

Source: packages/core/src/identity/envelope.ts (sealEnvelope, openEnvelope, boundContext), packages/core/src/identity/hpke.ts (hpkeSeal, hpkeOpen, sealTo, openFrom). Tests: packages/core/tests/envelope.test.ts.


10. Contact keys, member keys and inbox keys

P-256 key-agreement keys that others seal to with HPKE (§9.2).

10.1 Contact key

A P-256 key-agreement key that lets anyone seal a message only the account can open, while the account is offline.

d_contact = ScalarFrom(HKDF(seed, "minca/p256-contact-key/v1", 48))
  • It is not the root key (one key should not both sign and decrypt) and not a session key (one may last only a run, and its note an hour; a request read next week must still open).
  • The public half, as published, is base64url(compressed point) — 44 characters. A valid public contact key is a string of at most 64 characters that decodes to a point on P-256.
  • The public half is published in the account's profile record in each space it writes in (sys.profile, field contactKey; 03 — Spaces). The private scalar stays with the account home, and is handed only to apps granted contacts (06).

Example (seed of §3.3): scalar 1d513eb757cd656fa9aee6e6f54a0506965be5279c281ae9520601bece578201, public AwFM-6h0Js6WTFMPqM9EDDkDHknhhgfst2aD-fROgWoV.

10.2 Member keys

One key-agreement key per account per space, to which a private space's new keys are sealed (03 — Spaces, sys.box).

accountKey = HKDF(seed, "minca/account-key/v1", 32)        // §11.1
d_member   = ScalarFrom(HKDF(accountKey, "minca/p256-member-key/v1|" ‖ spaceId, 48))

The public half has the same encoding as the contact key. Being per space, an account home can give an app the member keys for exactly the spaces it grants.

Example: seed of §3.3, spaceId = "bexample" → scalar 692597e89fbbfd804d1b090c1a02219cc869a4151860a427c724b5e397c1121a.

10.3 Inbox keys

One key-agreement key per inbox generation (the address the account hands out so that strangers can ask to become a contact; 07 — Inbox):

d_inbox = ScalarFrom(HKDF(d_contact_bytes, "minca/p256-inbox-key/v1|" ‖ gen, 48))

where d_contact_bytes is the contact key's 32-byte big-endian private scalar (§10.1) and gen is a whole number from 0 written in decimal ASCII. Beside it, each inbox has a signing key, used only for ECDSA:

d_inbox_sign = ScalarFrom(HKDF(d_contact_bytes, "minca/p256-inbox-sign-key/v1|" ‖ gen, 48))

Every device and app holding the contact key derives the same inbox keys. Their public halves (same encoding as the contact key) say nothing about the account behind them, and a new gen gives keys nothing ties to the old ones.

Source: packages/core/src/identity/contact-key.ts. Tests: packages/core/tests/contacts.test.ts ("the contact key"), packages/core/tests/key-change.test.ts, packages/core/tests/inbox.test.ts ("an inbox code"), packages/core/tests/account-vault.test.ts ("the account key, and the spaces derived from it, match the spec's examples").


11. The account vault

The seed is never stored in the clear. What is stored is an account vault: the account's DID and a list of wraps, each an encrypted copy of the seed openable one way. The recovery code needs no wrap.

11.1 The local key and the account key

Two keys are derived from the seed beside the root key, each for one job:

localKey   = HKDF(seed, "minca/local-key/v1", 32)      // AES-256-GCM
accountKey = HKDF(seed, "minca/account-key/v1", 32)    // 32 bytes, only ever HKDF input
  • The local key seals what the account's devices keep for themselves: the registry at rest (05 — Sync and storage §15). It MUST NOT be handed to an app or another party; an implementation SHOULD hold it as a non-extractable key.
  • The account key is the root of the member keys (§10.2) and of the account's own spaces (03 — Spaces §13.1). It is what an app given the whole account receives (06), and what a node that follows the account's registry is given.

Neither is derivable from the other, but that keeps less from an app holding the account key than it may seem. The account key opens the account's own spaces (03 §13.1), whose versions a folder stores unsealed like any space's, and their sys.joined records carry the key of every private space the registry lists. What the local key alone keeps from such an app is the sealed entries of the registry store (05 §15) that nothing else carries: invite secrets waiting to be used (spaceinvite:) and the CLI's writer keys.

Example (seed of §3.3): local key 4b41df023aa99109c19c859f1228674a607d3d5cdfc10f2d748d582636ab9a3d, account key 9d1e01f2d4be2c948e921ac2604087a0b1448422b20d64f88e60f63238e87c9b.

11.2 Vault format (version 1)

{
  "version": 1,
  "label": "Ada",
  "did": "did:key:zDnaeUEY3vrNH2aBdwvsv4qvZpDJ2qHm3Z6UFkorqWJw3CmZT",
  "createdAt": "2026-10-13T19:44:45.107Z",
  "wraps": [
    {
      "kind": "device",
      "id": "bgRxJ2h0XcM",
      "label": "app.example",
      "rpId": "app.example",
      "deviceKeyId": "q1w2e3r4t5y6u7i8",
      "credentialId": "AbCdEf",
      "userHandle": "Zz9",
      "addedAt": "2026-10-11T10:15:34.769Z",
      "sealed": "AUZA8LNaPI83gl2TiZd5kLY3FAPWW9nZ_L-6dwATLfTn3ApNO9Me7UNtmKS8"
    },
    {
      "kind": "passphrase",
      "id": "w09cUHjZC7k",
      "label": "Passphrase",
      "addedAt": "2026-10-13T19:44:45.107Z",
      "salt": "uSTGCMafmVGh1iPTcEQ2RA",
      "iterations": 600000,
      "sealed": "ASKGZ7foaMlbiCtMDmgwKfGzVX8nQwaEGmMnIZ6IoIObeD1jqU_MmXEvZdau"
    }
  ]
}

(The passphrase wrap above opens with hunter2 hunter2 to the seed of §3.3. The device wrap's sealed is illustrative: its device key is not given.)

FieldTypeMeaning
version1Format version
labelstringDisplay name; never part of any key
didstringThe DID the seed derives. Public; lets a store recognise the account without unlocking it
createdAtISO 8601 string
wrapsarray of wrapMay be empty: a new account has none, and is opened by its recovery code

Every wrap:

FieldTypeMeaning
kind"device" | "passphrase"How it opens
idstringbase64url of 8 random bytes (11 chars); distinguishes wraps
labelstringShown when choosing
addedAtISO 8601 string
sealedstringbase64url of the envelope (§9.1) around the 16-byte seed: 45 bytes

device wraps add:

FieldTypeMeaning
rpIdstringOrigin (WebAuthn relying-party id, normally the hostname) whose storage holds the key
deviceKeyIdstringId of the device key (§12) that opens it
credentialIdstring, optionalbase64url id of the passkey gating it
userHandlestring, optionalbase64url user handle of that passkey

passphrase wraps add:

FieldTypeMeaning
saltstringbase64url of 16 random bytes
iterationsintegerPBKDF2 rounds; at least 600 000, at most 10⁷

11.3 Wrapping and unwrapping

A wrap is an envelope (§9.1) with label minca/vault-wrap/v1 around the seed:

  • Device wrap: key = the device key (§12); bound = { "did": <vault did>, "id": <wrap id>, "kind": "device" }.
  • Passphrase wrap: key = PBKDF2-HMAC-SHA256(UTF-8(NFKC(passphrase)), salt, iterations, 256 bits); bound = { "did", "id", "kind": "passphrase", "salt", "iterations" }, with the wrap's own values.

Unwrapping:

  • A reader MUST refuse a passphrase wrap whose iterations is not an integer from 600 000 to 10 000 000, before running PBKDF2.
  • It MUST open the envelope with the bound built from the vault's did and the wrap's own fields, so a wrap moved from another vault, or edited (its id, kind, salt or iterations), does not open.
  • After opening, it MUST derive the DID from the seed (§3) and refuse a seed whose DID is not the vault's did, or that is not 16 bytes.
  • Any of these failing is reported as VAULT_UNLOCK_FAILED: the wrong key or passphrase looks the same as a planted wrap.

11.4 Managing wraps

  • A vault holds at most one device wrap per rpId: adding one replaces any existing device wrap for the same rpId, and the replaced wraps' device keys SHOULD be deleted.
  • Removing wraps, including the last, is allowed; the account remains openable by its recovery code.
  • When two copies of a vault are merged, wraps are unioned by id, subject to the one-device-wrap-per-rpId rule.

A passphrase wrap is not tied to an origin: anything that can read the vault can try it, and a copy of the vault can be attacked offline. Which wraps a client offers, and how it manages passwords, are the library's: see Ways in.

Source: packages/core/src/identity/account-vault.ts (createVault, wrapSeedWithDeviceKey, unwrapSeedWithPassphrase, deriveLocalKey, deriveAccountKey). Tests: packages/core/tests/account-vault.test.ts ("a wrap from another account does not open as this one", "a passphrase wrap edited to fewer rounds, another salt or another id does not open", "the account key does not open the registry store's sealed entries").


12. Device keys

A device key is a random AES-256-GCM key kept by one origin (the wrap's rpId). It opens the device wrap whose deviceKeyId names it.

AlgorithmAES-GCM, 256 bits
Idbase64url of 12 random bytes (16 characters)
  • A missing device key is a normal state (other browser, cleared storage): the wrap is unopenable here and the account falls back to its recovery code or a passphrase.
  • Deleting the device key makes its wrap permanently unopenable.

How a device key is kept, and what that protects against, are the library's: see Device keys and passkeys.

12.1 Staying signed in (implementation detail)

Not protocol: see Staying signed in.

Source: packages/core/src/identity/device-key.ts. Tests: packages/core/tests/account-vault.test.ts ("wrapping a seed").


13. Passkeys and other ways to a root key

A passkey yields no key material: it is a gate in front of a device key (§12), enforced by the client, not by cryptography. A device wrap records the passkey that gates it in credentialId and userHandle (§11.2).

13.1 Passkeys are a gate

Not protocol: see Device keys and passkeys.

13.2 WebAuthn parameters

Not protocol: see Device keys and passkeys.


14. Account stores and folder accounts

Where vaults are kept. These are local storage formats; a folder's format matters for interoperability between apps (and origins) that open the same folder.

14.1 Folder layout

<folder>/
  accounts.json                 list of accounts, readable without unlocking
  accounts/<id>/account.json    that account's vault (§11.2)
  accounts/<id>/stores/…        that account's registry and spaces (05 §14)

accounts.json:

{
  "version": 1,
  "accounts": [
    {
      "id": "k3j9x0q2mz7a",
      "name": "Ada",
      "did": "did:key:zDnae…",
      "createdAt": "2026-09-26T11:47:11.062Z",
      "dataPath": "accounts/k3j9x0q2mz7a/stores",
      "lastUsedAt": "2026-09-26T12:00:00.000Z"
    }
  ]
}
FieldTypeMeaning
idstring matching ^[a-z0-9]{1,12}$Account id and directory name. New ids: base64url of 8 random bytes, non-alphanumerics removed, lowercased, first 12 characters
namestringDisplay name
didstringAccount DID
createdAtISO 8601
dataPathstringWhere the spaces live, relative to the folder: accounts/<id>/stores
lastUsedAtISO 8601, optional

Rules:

  • Readers MUST skip rows whose id fails the pattern, or whose dataPath is not accounts/<valid id>/stores exactly (the list is a plain file in a folder that may be shared; a path like ../.. is an attack).
  • A reader keeps at most one row per DID: the most recently used (lastUsedAt, else createdAt).
  • Writing an account replaces any row with the same id or the same did.
  • Removing an account removes its row and its accounts/<id> directory.

The list reveals how many accounts a folder holds, their names and DIDs; not their seeds.

14.2 Browser store (implementation detail)

Not protocol: see Account stores.

Source: packages/core/src/identity/account-store.ts. Tests: packages/core/tests/account-store.test.ts, packages/core/tests/account-vault.test.ts ("the account file"), packages/cli/tests/path-safety.test.ts.


15. Pairing

Pairing gives a second device (typically a phone, which cannot open a folder) the account and the list of its spaces. Afterwards the phone is a full, independent peer; the first device is not needed again.

The QR code carries a one-time secret and nothing about the account. The two devices meet in a room named after the secret, run an ephemeral key exchange bound to it, and both show a six-digit code. The offering device sends the seed only after the person confirms the codes match, sealed under a key no other exchange shares, one for each direction. A photo of the QR code is useless once a phone has used it or a few minutes have passed, and a recording of the room opens nothing.

15.1 The ticket

{ "v": 2, "secret": "AAECAwQFBgcICQoLDA0ODw", "relay": "wss://relay.example" }
FieldTypeMeaning
v2Version
secretstringbase64url of 16 random bytes, new for every offer
relaystringA relay URL the offering device is connected to and the phone can reach

Encoding: base64url(UTF-8(JSON)), placed in a URL fragment as <page>#pair=<ticket> and shown as a QR code. Readers find it with /[#&]pair=([^&]+)/ on the fragment. A decoder MUST reject a ticket that is not base64url JSON, whose v is not 2, whose relay is not a string, or whose secret does not decode to exactly 16 bytes (PAIRING_TICKET_UNREADABLE). Version 1 tickets carried the recovery code and MUST be refused. The page SHOULD remove the fragment from the address bar once read.

Example (the ticket above): eyJ2IjoyLCJzZWNyZXQiOiJBQUVDQXdRRkJnY0lDUW9MREEwT0R3IiwicmVsYXkiOiJ3c3M6Ly9yZWxheS5leGFtcGxlIn0

The offering device MUST generate the secret with a CSPRNG for each offer and MUST NOT derive it from the seed.

15.2 The channel

Pairing runs on a channel: two programs that share a one-time secret and nothing else meet in a room named after it, swap ephemeral keys, and seal every later message to each other. Connecting a program by code runs on the same channel with its own labels (06 — Nodes, sessions and apps §3). Each use names a kind, three labels:

Kindroomkeymessage
Pairingminca/pairing-room/v1minca/pairing-key/v1minca/pairing/v1
Connect by codeminca/code-room/v1minca/code-key/v1minca/code/v1

In pairing, the side that made the secret and shows it is offer, and the side that joins with it is join. In connecting by code the account home is offer and the program, which made the code, is join. Both join mesh room (04 — Network):

room = CID(UTF-8(room label) ‖ secret)     // raw concatenation

Example (pairing, the secret above): room bha5qohdu5w35oenvib6zfdg4blcbzc4onwelk4nium6ooen6nq7q.

Each side makes an ephemeral P-256 ECDH key for this exchange only. O is the offering side's public point and J the joining side's, each as the 65-byte uncompressed SEC 1 point (04 ‖ x ‖ y). With Z the 32-byte ECDH shared x-coordinate:

ikm        = secret ‖ Z
transcript = "|" ‖ base64url(O) ‖ "|" ‖ base64url(J)
offerKey   = HKDF(ikm, key label ‖ "|offer" ‖ transcript, 32)   // AES-256-GCM: what the offering side sends
joinKey    = HKDF(ikm, key label ‖ "|join" ‖ transcript, 32)    // AES-256-GCM: what the joining side sends

A point that is not 65 bytes starting 04, or that is not on the curve, MUST be refused (PAIRING_TICKET_UNREADABLE).

A sealed message is an envelope (§9.1), sent as a base64url string: under the key of the side that sends it, with the kind's message label and bound { "type": <the message's type>, "seq": <n> }, where n counts the sealed messages that side has sent in this exchange, from 0. A receiver keeps its own count of what it has opened from the other side, and MUST refuse a message that does not open with the other side's key, its own type and that count; a refused message leaves the count as it was. So a message sent back to the side that sealed it, presented as another type, replayed or delivered out of order does not open.

Pairing adds a short code both screens show, and a commitment (connecting by code adds the same, under minca/code-check/v1; 06 §3):

code       = uint32_be(HKDF(ikm, "minca/pairing-code/v1" ‖ transcript, 4)) mod 1000000,
             as 6 decimal digits, zero-padded
commitment = base64url(SHA-256(J))

Rationale: the secret is in ikm, so anyone who never had it gets a different key and a different code. Z is in ikm too, so someone who records the room and learns the secret later still opens nothing. The points are in the labels, so a payload sealed in one exchange opens in no other, even one with the same secret; the kind's labels keep pairing and connecting by code apart. In pairing the phone commits to J before it sees O, and the offering device sends O before it sees J, so neither side, nor anyone between them, can choose a key to make two different exchanges show the same code; one guess in a million is all that is left. The secret is high-entropy, so a PAKE would add nothing an HMAC or this key schedule does not. A key per direction, and a count in each message, mean nobody in the room can turn one side's message into something the other said, or say it twice.

15.3 The exchange

Both devices join the pairing room (§15.2; the phone is the joining side) under a throwaway did:key of their own, not a session DID. The offering device joins with its configured relays; the phone joins with exactly [ticket.relay]. Every message is a NetworkMessage { type, from, payload }:

Offering device                                       Phone
  shows QR: <link>#pair=<ticket>
  joins room                                          reads ticket, joins room via ticket.relay
            ◀─────────────── peer connected ───────────────▶
                         ◀── pair:commit  { "commit": commitment }
  pair:key { "key": base64url(O) } ──▶
                         ◀── pair:reveal  { "key": base64url(J) }
  checks SHA-256(J) against the commitment
  shows code                                          shows code
  the person confirms the codes match
  pair:handover  sealed { "seed", "spaces" } ──▶
                                                      opens, checks
                         ◀── pair:done    sealed { "spaces": n }
                                                      signs in, joins each invite
  • Handover plaintext: UTF-8 JSON { "seed": base64url(seed), "spaces": [<invite>, …] }, where each invite is an invite to the space (03 — Spaces), carrying whatever a peer needs to open it, including a private space's key. A phone MUST refuse a handover whose seed does not decode to 16 bytes, or whose spaces is not an array of strings.
  • Offering device:
    • answers the first pair:commit only. From then on the offer is spent: it MUST NOT answer another commit, and MUST ignore pair:reveal and pair:done from any other peer. A commit is not authenticated, so the relay, or anyone in the room, can spend an offer: denial of service only.
    • MUST stop answering a commit after a short time with no phone (two minutes in this implementation), and when the person stops the offer.
    • on pair:reveal, MUST check the commitment and finish with a failure if it does not match.
    • MUST NOT send pair:handover until the person has confirmed that both screens show the same code. If they say the codes differ, it finishes and sends nothing.
  • Phone: ignores messages of other types, and answers only the first pair:key. It shows the code and waits for pair:handover from the peer that sent the key. If the payload does not open, or is not a handover, it finishes with a failure. It sends pair:done, with the number of invites the handover carried, once a handover opens and checks out, before it signs in: it says the account arrived, not that the person kept it.
  • A device that holds an account and opens a #pair= link SHOULD warn before taking the account on: the link can come from someone else, and the code check cannot tell a stranger's computer from your own.

Source: packages/core/src/identity/channel.ts (deriveChannel, channelRoom, PAIRING_CHANNEL, CODE_CHANNEL), packages/core/src/identity/pairing.ts (pairingCode, pairingCommitment), packages/core/src/session/channel-room.ts (meetOnChannel), packages/core/src/session/pairing.ts (offerToPhone, receiveFromDesktop), packages/core/src/session/auth.ts (start, acceptPairing). Tests: packages/core/tests/take-up.test.ts ("a pairing link opened where a sign-in is kept"), packages/core/tests/pairing.test.ts ("a message sent back to the side that sealed it does not open there", "a message relabelled as another type does not open", "a message replayed, or delivered out of order, does not open", "someone who learns the secret later cannot open a recording of the exchange", "pairing and connecting by code never open each other’s messages").


16. Not yet specified

  • A checksum or version marker for recovery codes: planned in §2.3.
  • Rotation of a compromised seed. Today an account is its seed; a new seed is a new identity. A proposal (KERI-style pre-rotation: the identifier is the hash of an inception event committing to the next key, with a signed key event log synced like records) is issue #9. It would change the DID format everywhere a root appears.

On this page