e2ee

package
v0.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 26, 2026 License: MIT Imports: 14 Imported by: 0

Documentation

Overview

Package e2ee @notice Client-side encryption: the per-user KDF parameters a browser needs, and one opaque wrapped root key per user.

@dev This package cannot decrypt anything, and that is the product. It imports no AEAD and no password KDF — the whole crypto surface here is an HMAC that derives a salt. If kal could decrypt, this would be server-side encryption with extra ceremony, which is a different thing sold under the same word. TestE2EEImportGraph is what holds the line, because the failing version always arrives looking like a helpful convenience method.

The refusal this package carries: browser-delivered end-to-end encryption does not protect against the server that serves the JavaScript. An operator who wants the plaintext ships one line of JS to one user and has their master key on the next page load, and no amount of care in this Go package changes that. Anyone who tells you otherwise is selling something. The honest claim is that kal cannot read your data, and neither can anyone who reads your database.

See docs/e2ee-client.md for the wire format and the derivation, and README.md for what enabling this costs a deployment.

Index

Constants

View Source
const (
	// KDFArgon2id @notice What every new enrolment gets. Not in WebCrypto; the reference client
	// takes it from hash-wasm.
	KDFArgon2id = "argon2id"
	// KDFPBKDF2 @notice The native browser fallback, kept openable rather than endorsed. It is
	// GPU-cheap, so a user on it should be re-wrapped to Argon2id on their next login.
	KDFPBKDF2 = "pbkdf2"
)

The client KDF names the `kdf` column may hold.

Variables

This section is empty.

Functions

func NewRecoveryCode

func NewRecoveryCode() (string, error)

NewRecoveryCode @notice Mints a recovery code: 32 bytes from crypto/rand, base64url, shown once.

@dev session.NewToken's shape, and its hash is deliberately discarded — there is nothing to store, because the wrapped key *is* the verifier. Nothing anywhere can check a recovery code except an attempt to unwrap with it, which is what keeps a stolen database from containing a second route into the vault.

The code survives a password reset, because it wraps the vault key rather than being wrapped by the password. That is the intent: it is the only route back into a vault whose password moved.

@return string the code, to show the user once and never again @return error only a CSPRNG failure

func ValidateAuthSecret

func ValidateAuthSecret(s string) error

ValidateAuthSecret @notice Accepts "kal1." followed by 43 base64url characters, and nothing else.

@dev The single most likely way to break a deployment of this feature, and one shape check prevents all of it. Without it a client that sends the raw password — an un-updated mobile app, a curl in a runbook, a test fixture, a second frontend nobody remembered — logs in successfully, and the account is now hashed over a value the vault key was not derived from. Login works, the vault silently never opens, nothing errors and nothing logs (gotcha 65).

The length check and the strict decode are one control in two halves, and neither covers the other. Strict decoding rejects the non-canonical encodings a character-class regex would wave through; the length check rejects the whitespace the decoder itself waves through, because encoding/base64 skips \r and \n wherever they appear and Strict() governs only padding and trailing bits (gotcha 78).

@param s the value arriving in the field where the password used to go @return error a *kalerr.Error with CodeInvalidInput, or nil

Types

type Options

type Options struct {
	// Pepper @notice Required, at least 32 bytes, and stable for the life of the deployment.
	//
	// @dev No default, and deliberately not generated when missing: a per-replica or per-restart
	// pepper moves the salt under the user's feet, and every wrapped key in the deployment becomes
	// garbage with a working login and no error. It is not protecting the salt — salts are not
	// secrets — it is what stops an attacker who knows the derivation, which is everyone, from
	// distinguishing the decoy salt of an unknown address from a real one.
	Pepper []byte

	// Default @notice Handed to new accounts and to addresses with no vault. Zero fields take
	// Argon2id at 64 MiB, 3 passes — roughly a second on a mid-range phone.
	Default Params

	// Floor @notice The minimum accepted on write. Zero fields take OWASP's m=19 MiB, t=2.
	//
	// @dev In Go rather than a check constraint, because a floor that is expected to rise over
	// time should not be a migration. Only `parallelism = 1` lives in the DB.
	Floor Params

	// MaxBlob @notice Byte ceiling on each stored blob. Zero means 8192.
	MaxBlob int

	// Schema @notice Optional Postgres schema holding the auth_* tables.
	Schema string

	// AllowNoRecovery @notice Lets a vault be written with no recovery wrapping. Off by default.
	//
	// @dev Named for the opt-out so that false is the safe posture. With recovery required, a user
	// who forgets their password has one route back; without it they have none, and the support
	// queue that produces cannot be answered by anyone, including the operator.
	AllowNoRecovery bool
}

Options @notice Configuration for NewVaults. Pepper is required; the zero value of everything else is the production posture.

type Params

type Params struct {
	KDF        string
	Salt       []byte // 16 bytes, minted at first Put and never rotated
	Memory     uint32 // KiB; meaningless for pbkdf2, still recorded
	Iterations uint32
	// Parallelism @notice Always 1, on write and on read.
	//
	// @dev Argon2's p changes the output, so a browser that picks it from the thread pool derives
	// a different key on a laptop than on a phone and the second device reports a corrupt vault
	// (gotcha 66). The column carries a check constraint saying the same thing.
	Parallelism uint8
}

Params @notice The client-side KDF parameters for one account, stored per user.

@dev Stored rather than read from configuration at derive time: raise a default and every existing vault becomes unopenable, with a working login and no error anywhere (gotcha 67).

type Vault

type Vault struct {
	WrappedKey         []byte
	RecoveryWrappedKey []byte
	// Params @notice The client KDF this wrapping was produced under. Salt is ignored on write —
	// kal mints it, because a client-chosen salt would break the decoy indistinguishability that
	// Params depends on.
	Params Params
	// KeyVersion @notice The version this write expects to replace. Zero means "no row yet".
	KeyVersion int
	// Stale @notice The account's password moved since this key was wrapped, so the key derived
	// from the current password cannot open it.
	//
	// @dev Read-only; ignored on write. Without it the client receives a key it has no possible
	// way to open and fails somewhere far from the cause (gotcha 74).
	Stale bool
}

Vault @notice One user's wrapped root key, as kal stores it. Every byte field is opaque here.

type Vaults

type Vaults struct {
	// contains filtered or unexported fields
}

Vaults @notice Reads and writes the one vault row per user, and answers the pre-auth parameter query every client makes before it can derive anything.

@dev Carries no *pg.DB: every method takes orm.DB, so a consumer can put a vault write in the same transaction as the change that motivated it.

func NewVaults

func NewVaults(opts Options) (*Vaults, error)

NewVaults @notice Validates opts, fills the parameter defaults and prepares the statements.

@param opts Pepper is required @return *Vaults ready for concurrent use @return error a missing or short pepper, or an invalid schema name

func (*Vaults) Discard

func (v *Vaults) Discard(ctx context.Context, db orm.DB) error

Discard @notice Deletes the caller's vault row.

@dev For the user who reset their password, could not produce the old one and has given up. Deleting is theirs to ask for; kal does not do it on their behalf inside a password reset, because that would throw away a state they can still recover from.

@return error UNAUTHENTICATED, or a driver failure

func (*Vaults) Get

func (v *Vaults) Get(ctx context.Context, db orm.DB) (*Vault, error)

Get @notice The caller's vault, or nil if they never enrolled.

@dev Reads the user from the context principal, never from a parameter — an id parameter on a vault method is an IDOR waiting for one resolver to pass the wrong variable. Absence is not an error, the same way session.Lookup treats an unknown token.

@return *Vault the wrapped keys and the staleness verdict; nil when there is no row @return error UNAUTHENTICATED, or a driver failure

func (*Vaults) Params

func (v *Vaults) Params(ctx context.Context, db orm.DB, email string) (Params, error)

Params @notice The KDF parameters for an address, whether or not it has an account.

@dev Necessarily pre-auth: the client needs the salt before it can produce the auth secret, so it is asking about an account it has not authenticated to. Returning nil or an error for an unknown address is an account-enumeration oracle, so every address gets an answer. This also solves registration's chicken and egg — the client derives before the account exists, asks, and keeps what it gets — which is why authn.Register needs no knowledge of this package.

Do not rate-limit this through auth_login_attempts: that table is the login backoff, and feeding it from an unauthenticated endpoint lets anyone lock any account out by repeatedly asking for its salt (gotcha 69). Rate-limit at the edge.

@param email the address to derive for; normalised here @return Params the stored parameters, or the deterministic decoy plus Options.Default @return error only a driver failure

func (*Vaults) Put

func (v *Vaults) Put(ctx context.Context, db orm.DB, vault Vault) error

Put @notice Writes the caller's wrapped key, compare-and-swap on KeyVersion.

@dev One statement, never a read followed by a write (gotcha 36's shape): two devices re-wrapping after a password change is a real race, not a theoretical one, and the loser has to learn that it lost rather than silently clobber the winner. Zero rows affected is CodeConflict.

The stored salt is minted here on the first write and is never rotated afterwards, because a salt that moves when the email moves turns every wrapped key in the deployment into garbage, silently, with a working login. Rotating it is a re-wrap flow, not a maintenance task.

@param vault the wrapped blobs, the parameters they were produced under, and the version this

write expects to replace; Params.Salt and Stale are ignored

@return error UNAUTHENTICATED, INVALID_INPUT for a blob or parameter the policy rejects,

CONFLICT when another write got there first, or a driver failure

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL