CARLOS rastrillo docs

๐Ÿค– keyring

amadan.net/rastrillo/rastrillo/keyring

The E2EE seed lifecycle over crypto's primitives: one 32-byte seed per person, namespaced purpose derivation, the seed wrapped under a passkey, content keys granted to members, and the guard that keeps the last wrap unrevokable.

Two rulings bind everything here.

No new cryptography. Every operation composes crypto's golden-vectored primitives โ€” Derive, SealSym/OpenSym, Seal/Open. The keyring adds names, formats and ceremonies, never ciphers.

No storage. Pure functions plus wire formats, in both languages. The tables for wrapped seeds, grant rows and member public keys are yours.

The ring

type Ring struct{ Namespace string }

func (r Ring) PRFSalt() string
func (r Ring) ContentKey(seed []byte) []byte
func (r Ring) WrapKey(prf []byte) []byte
func (r Ring) BlobKey(seed []byte, name string) []byte

A Ring carries your namespace and derives every context string from it: PRFSalt is ns/prf/v1, ContentKey derives with ns/content/v1, WrapKey with ns/wrap/v1, and BlobKey derives one sealing key per named vault blob with ns/blob/<name>/v1 โ€” the name lands inside the context string, so the vault's closed namespace validates it before the ring ever sees it. Two apps on one keyring can never collide. Kass's existing strings fall out as the Ring{"kass"} case, byte-identically: its deriveBytes is HKDF-SHA256 with a zero-length salt, crypto.Derive passes a nil salt, and RFC 5869 treats the two the same.

Seeds

func NewSeed() ([]byte, error)

func (r Ring) WrapSeed(prf, seed []byte) ([]byte, error)
func (r Ring) UnwrapSeed(prf, wrapped []byte) ([]byte, error)

NewSeed mints the one per-person root everything else derives from. WrapSeed seals it under the key derived from a passkey's PRF output, as iv(12) โ€– AES-256-GCM ciphertext. Wrapping the same seed under a different credential's PRF output is the whole of device add and RPID move. UnwrapSeed reverses it, and a wrong credential, a wrong namespace and a tampered blob all fail the same way.

The transport is yours to build, and the contract is short: store a wrapped seed keyed by credential ID, return it at sign-in, accept a new one at enrol.

An RPID move runs in three phases.

Under the old name, nothing special happens: the seed is wrapped under old-RPID passkeys.

At crossover you serve under the new RPID with webauthn's Config.LegacyRPID set to the old one, for assertions only. A fresh device signs in through the legacy fallback, unwraps the seed, and enrols a new-RPID credential โ€” same seed, another WrapSeed.

Once settled, drop LegacyRPID. Only new-name credentials remain.

Grants

func (r Ring) Grant(memberBoxPub, contentKey []byte) ([]byte, error)
func (r Ring) OpenGrant(memberBoxPriv, sealed []byte) ([]byte, error)

A member is the box half of a keypair. Grant wraps a content key โ€” one key, one instance, never the seed โ€” with crypto.Seal under ns/grant/v1, as ephPub(65) โ€– iv(12) โ€– ciphertext.

OpenGrant takes the raw 32-byte box private scalar instead of a full crypto.Keypair, because member identities are ECDH-only pairs. The JS twin accepts the pkcs8/JWK import you already hold.

Revocation is your server deleting the grant row. Nothing cryptographic happens. If you want to re-key afterwards, mint a new content key and re-grant to the remaining members; the package adds no machinery for that ceremony.

Wraps

type Wrap struct {
	ID, Kind, Label, UID string
	CredentialID         []byte
	Wrapped              []byte
}

func AddWrap(wraps []Wrap, w Wrap) []Wrap
func RemoveWrap(wraps []Wrap, id string) ([]Wrap, error)

One lifecycle rule is enforced in code, because getting it wrong loses data forever: the last wrap cannot be revoked. RemoveWrap returns ErrLastWrap instead of leaving a seed with zero wraps, which would be a seed nobody can ever open again. An ID it cannot find gets ErrUnknownWrap.

AddWrap dedupes by ID, replacing an existing wrap in place. Both functions are pure: your input slice is never mutated, and you persist wraps wherever you like.

If you fold untrusted input in an event-sourced consumer, treat ErrLastWrap as a no-op rather than a crash. These errors are for interactive callers.

JS

func JS() []byte

The WebCrypto twin as an embedded ES module, js/keyring.mjs. It imports ./crypto.mjs, so serve crypto.JS() beside it under the same mount โ€” that sibling layout is the deployment contract.

It passes the same golden vectors as the Go package. One trade is made out loud: contentKey and wrapKey return raw bytes, giving up non-extractable-CryptoKey hygiene for wrappability, because a grant cannot wrap a key it cannot read.

Read this page as markdown โ€” exact, unstyled, and cheap for an agent to fetch.