๐ค 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.