zkauthn

package
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Aug 9, 2026 License: MIT Imports: 32 Imported by: 0

Documentation

Overview

Package zkauthn @notice Groth16 authentication and anonymous authorization over BN254.

@dev The operator that verifies proofs also performs each circuit's trusted setup. That operator can already bypass its own verifier, so single-party setup does not weaken the soundness property against users. No setup output is shipped by kal.

Index

Constants

View Source
const (
	// MerkleDepth @notice The fixed credential-tree depth and therefore part of the circuit.
	MerkleDepth = 32
	// SecretSize @notice Bytes in a generated ZK secret; every 31-byte value is canonical BN254.
	SecretSize = 31
	// KnowledgeConstraints pins the compiled R1CS cost under gnark v0.15.0.
	KnowledgeConstraints = 332
	// MembershipConstraints pins the compiled R1CS cost under gnark v0.15.0.
	//
	// @dev v0.15's two-input MiMC absorbs two field blocks at 330 constraints each, so the
	// 32 tree compressions account for ~21k constraints. The handout's 20k estimate assumed
	// one permutation per two-to-one compression; the measured API semantics are pinned here.
	MembershipConstraints = 23001
	KnowledgeCircuitID    = "27262b1163cbc35c4fb83ec828234b98dc324f9f351681cf781ccf7529c1fe3f"
	MembershipCircuitID   = "5f8c74c31dfa7fe1575ce175856d4ed02f5e657d2fb8a7d0208279ef63f6370f"
)
View Source
const (
	// ChallengeTTL @notice Lifetime of a server-issued proof challenge.
	ChallengeTTL = time.Minute
)

Variables

This section is empty.

Functions

func CircuitInfo

func CircuitInfo(kind Circuit) (constraints int, id [sha256.Size]byte, err error)

CircuitInfo @notice The measured size and serialized identity of a compiled circuit.

func KnowledgeValid

func KnowledgeValid(w KnowledgeWitness) bool

KnowledgeValid @notice Plain-Go oracle for KnowledgeCircuit.

func MembershipValid

func MembershipValid(w MembershipWitness) bool

MembershipValid @notice Plain-Go oracle for MembershipCircuit.

func ProofSize

func ProofSize() int

ProofSize @notice The exact compressed proof length accepted before deserialization.

func Setup

func Setup(kind Circuit, pkw, vkw io.Writer) error

Setup @notice Runs the operator ceremony for one circuit and writes separate proving and verifying keys.

@dev The two writers prevent the multi-megabyte proving key from being mounted beside a server that only needs the verifying key. Neither output belongs in this repository.

Types

type Circuit

type Circuit string

Circuit identifies one setup-compatible constraint system.

const (
	// CircuitKnowledge identifies KnowledgeCircuit and its setup artifacts.
	CircuitKnowledge Circuit = "knowledge-v1"
	// CircuitMembership identifies MembershipCircuit and its setup artifacts.
	CircuitMembership Circuit = "membership-v1"
)

type Claim

type Claim struct {
	Name      string
	Audience  Field
	Threshold uint64
	Kind      ClaimKind
	// AllowsLogin @notice Whether proving this claim may mint a session. False by default: Kind
	// distinguishes how long a nullifier lives, not what a proof is good for, so without this a
	// claim written for an @auth(proves:) step-up is also a login endpoint.
	AllowsLogin bool
}

Claim @notice Server policy supplying the audience and threshold for a schema claim name.

type ClaimKind

type ClaimKind string

ClaimKind @notice Whether a nullifier is a recurring pseudonym or a one-shot allowance.

const (
	ClaimRecurring ClaimKind = "recurring"
	ClaimOneShot   ClaimKind = "one_shot"
)

type Credential

type Credential struct {
	Secret    Secret
	Attribute uint64
	LeafIndex uint32
	Path      MerklePath
}

Credential @notice A membership credential returned once when the operator issues it.

type Field

type Field [fr.Bytes]byte

Field @notice A canonical, big-endian BN254 scalar-field encoding.

func ChallengeField

func ChallengeField(token string) (Field, error)

ChallengeField @notice Converts a server challenge token into its canonical circuit input.

func KnowledgeCommitment

func KnowledgeCommitment(secret Secret) (Field, error)

KnowledgeCommitment @notice Computes the public commitment a client proves knowledge of.

func MembershipCommitment

func MembershipCommitment(secret Secret, attribute uint64) (Field, error)

MembershipCommitment @notice Computes the raw credential leaf bound to secret and attribute.

func NewAudience

func NewAudience(deployment, policy, epoch string) Field

NewAudience @notice Derives a canonical audience field from deployment and policy labels.

@dev SHA-256 output is reduced deliberately into BN254. Audience collision resistance is therefore the field size (~254 bits), and the versioned prefix prevents reuse by another protocol in the same process.

func Nullifier

func Nullifier(secret Secret, audience Field) (Field, error)

Nullifier @notice Computes the recurring or one-shot public pseudonym for a credential audience.

type KnowledgeCircuit

type KnowledgeCircuit struct {
	Commitment frontend.Variable `gnark:",public"`
	Challenge  frontend.Variable `gnark:",public"`
	Secret     frontend.Variable
}

KnowledgeCircuit @notice Proves knowledge of a secret committed for the current session.

@dev Flattened form:

  1. h = MiMC(DOM_KNOWLEDGE, Secret)
  2. assert h == Commitment
  3. c2 = Challenge * Challenge

Statement 3 deliberately creates an otherwise-unused constraint. Without it Challenge is present in the public witness but not bound to the proof, so a proof replays under every server challenge.

func (*KnowledgeCircuit) Define

func (c *KnowledgeCircuit) Define(api frontend.API) error

Define @notice Declares the knowledge constraints above.

type KnowledgeRequest

type KnowledgeRequest struct {
	Proof     []byte `json:"proof"`
	Challenge string `json:"challenge"`
}

KnowledgeRequest @notice Proof bytes plus the server challenge for MFA step-up.

type KnowledgeWitness

type KnowledgeWitness struct {
	Secret     Secret
	Commitment Field
	Challenge  Field
}

KnowledgeWitness @notice Typed inputs for creating a knowledge proof.

type MembershipCircuit

type MembershipCircuit struct {
	Root      frontend.Variable `gnark:",public"`
	Audience  frontend.Variable `gnark:",public"`
	Threshold frontend.Variable `gnark:",public"`
	Nullifier frontend.Variable `gnark:",public"`
	Challenge frontend.Variable `gnark:",public"`
	Secret    frontend.Variable
	Attribute frontend.Variable
	Path      [MerkleDepth + 1]frontend.Variable
	Index     frontend.Variable
}

MembershipCircuit @notice Proves membership, one numeric threshold and an audience pseudonym.

@dev Flattened form:

  1. decompose Attribute into exactly 64 bits
  2. leaf = MiMC(DOM_LEAF, Secret, Attribute)
  3. assert leaf == Path[0]
  4. verify Path at Index recomputes Root
  5. assert Threshold <= Attribute
  6. n = MiMC(DOM_NULLIFIER, Secret, Audience)
  7. assert n == Nullifier
  8. c2 = Challenge * Challenge

Path[0] is private prover input. Statement 3 is what proves knowledge of the credential rather than mere knowledge of any public leaf. Statement 8 binds freshness just as it does in KnowledgeCircuit.

func (*MembershipCircuit) Define

func (c *MembershipCircuit) Define(api frontend.API) error

Define @notice Declares the membership constraints above.

type MembershipPublic

type MembershipPublic struct {
	Root, Audience, Nullifier, Challenge Field
	Threshold                            uint64
}

MembershipPublic @notice The public witness supplied by the verifier after policy lookup.

type MembershipRequest

type MembershipRequest struct {
	Proof     []byte `json:"proof"`
	Root      []byte `json:"root"`
	Nullifier []byte `json:"nullifier"`
	Challenge string `json:"challenge"`
	Claim     string `json:"claim"`
}

MembershipRequest @notice Attacker-supplied proof fields; policy values are deliberately absent.

type MembershipWitness

type MembershipWitness struct {
	Secret    Secret
	Attribute uint64
	Path      [MerkleDepth + 1]Field
	Index     uint32
	Root      Field
	Audience  Field
	Threshold uint64
	Nullifier Field
	Challenge Field
}

MembershipWitness @notice Typed inputs for creating a membership or threshold proof.

func MembershipWitnessFor

func MembershipWitnessFor(credential Credential, path MerklePath, claim Claim,
	nullifier Field, challenge Field) MembershipWitness

MembershipWitnessFor @notice Builds client prover inputs from a credential, path and policy.

type MerklePath

type MerklePath struct {
	Root  Field
	Path  [MerkleDepth + 1]Field
	Index uint32
}

MerklePath @notice The current root and sibling path for one credential leaf.

func SingleLeafPath

func SingleLeafPath(commitment Field) (MerklePath, error)

SingleLeafPath @notice Builds the sparse-tree path for the first and only credential.

@dev This is primarily a setup smoke-test and example helper. Live clients must call Path, because every later enrolment changes siblings and the root.

type Options

type Options struct {
	KnowledgeVK  *VerifyingKey
	MembershipVK *VerifyingKey
	Sessions     *session.Sessions
	// Hasher @notice Required. Re-verifies the password that gates replacing a commitment.
	Hasher     *authn.Hasher
	ProofSink  ProofSink
	CookieName string
	Schema     string
	RootGrace  time.Duration
	// MFAWindow @notice How recently MFA must have been satisfied for an account with no
	// password to replace its commitment. Zero means authz.DefaultMFAWindow.
	MFAWindow                  time.Duration
	MaxConcurrentVerifications int64
}

Options @notice Dependencies and production defaults for New.

type ProofSink

type ProofSink func(context.Context, VerifiedClaim) error

ProofSink adds a verified claim to the request-local authorization holder.

type ProvingKey

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

ProvingKey @notice A parsed Groth16 proving key and its matching compiled circuit.

func LoadProvingKey

func LoadProvingKey(kind Circuit, r io.Reader) (*ProvingKey, error)

LoadProvingKey @notice Safely parses a proving key for the named compiled circuit.

func (*ProvingKey) ProveKnowledge

func (p *ProvingKey) ProveKnowledge(w KnowledgeWitness) ([]byte, error)

ProveKnowledge @notice Creates a compressed Groth16 knowledge proof.

func (*ProvingKey) ProveMembership

func (p *ProvingKey) ProveMembership(w MembershipWitness) ([]byte, error)

ProveMembership @notice Creates a compressed Groth16 membership proof.

type Secret

type Secret [SecretSize]byte

Secret @notice A generated 248-bit credential that is always a canonical field element.

type VerifiedClaim

type VerifiedClaim struct {
	Name string
	Kind ClaimKind
}

VerifiedClaim @notice A claim whose proof and server policy were verified for this request.

type VerifyingKey

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

VerifyingKey @notice A safely parsed, hash-pinned Groth16 verifying key.

func LoadVerifyingKey

func LoadVerifyingKey(kind Circuit, r io.Reader, wantSHA256 []byte) (*VerifyingKey, error)

LoadVerifyingKey @notice Hash-pins and safely parses a BN254 verifying key.

func (*VerifyingKey) VerifyKnowledge

func (v *VerifyingKey) VerifyKnowledge(proof []byte, commitment, challenge Field) error

VerifyKnowledge @notice Verifies proof against server-supplied commitment and challenge.

func (*VerifyingKey) VerifyMembership

func (v *VerifyingKey) VerifyMembership(proof []byte, public MembershipPublic) error

VerifyMembership @notice Verifies proof against server-supplied policy public inputs.

type ZK

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

ZK @notice Knowledge MFA, membership credentials, anonymous login and proof-backed claims.

func New

func New(opts Options) (*ZK, error)

New @notice Validates dependencies, computes sparse-tree zeros and renders SQL once.

func (*ZK) Claim

func (z *ZK) Claim(ctx context.Context, db orm.DB, name string) (Claim, error)

Claim @notice Reads the server policy clients must use as membership public inputs.

func (*ZK) ClaimChallenge

func (z *ZK) ClaimChallenge(ctx context.Context, db orm.DB) (string, error)

ClaimChallenge @notice Issues a 60-second membership challenge bound to the current session.

func (*ZK) EnrollKnowledge

func (z *ZK) EnrollKnowledge(ctx context.Context, db orm.DB, currentPassword string) (Secret, error)

EnrollKnowledge @notice Generates a knowledge secret and returns it exactly once. Replacing an existing commitment requires re-authentication.

@dev A session cookie alone used to be enough to overwrite the commitment, so an attacker who stole one enrolled their own secret, proved it, set mfa_at and satisfied every @auth(mfa: true) field — the second factor defeated by the first, which is the definition of not being one. First enrolment stays open; replacement takes the other factor the account still has:

existing commitment | account has a password | required
none                | —                      | an authenticated session
yes                 | yes                    | the current password, re-verified
yes                 | no                     | MFAAt within the MFA window

The third row exists because re-enrolment is the documented recovery path for a lost secret, so it must be reachable without the old factor. An account with neither cannot self-serve, which is the operator's problem in the same way an unaccepted invite is.

Replacement revokes every other session rather than rotating like ChangePassword: a routine password change should keep other devices signed in, but replacing a second factor under a possibly-stolen session is the reset case.

@param currentPassword the account's current password; ignored on first enrolment @return Secret the new secret, returned once and never stored

func (*ZK) EnsureClaim

func (z *ZK) EnsureClaim(ctx context.Context, db orm.DB, claim Claim) error

EnsureClaim @notice Idempotently installs the server policy for one schema claim name.

func (*ZK) IssueCredential

func (z *ZK) IssueCredential(ctx context.Context, db orm.DB, issuedTo string, attribute uint64) (*Credential, error)

IssueCredential @notice Generates, inserts and returns one linked membership credential.

@dev issuedTo is normally the named auth_users id so revocation can name a person. Empty deliberately stores NULL for deployments that prefer an unlinked issuance registry.

func (*ZK) KnowledgeChallenge

func (z *ZK) KnowledgeChallenge(ctx context.Context, db orm.DB) (string, error)

KnowledgeChallenge @notice Issues a 60-second challenge bound to the current session.

func (*ZK) Login

func (z *ZK) Login(ctx context.Context, db orm.DB, req MembershipRequest) (*authz.Principal, error)

Login @notice Verifies recurring membership and issues an ordinary pseudonymous kal session.

func (*ZK) LoginChallenge

func (z *ZK) LoginChallenge(ctx context.Context, db orm.DB) (string, error)

LoginChallenge @notice Issues a 60-second challenge usable only for anonymous membership login.

func (*ZK) Path

func (z *ZK) Path(ctx context.Context, db orm.DB, index uint32) (*MerklePath, error)

Path @notice Returns the current Merkle path for an active credential index.

@dev A path and its root are one logical snapshot, and a proof built across a mutation verifies against no published root at all (gotcha 54). That reasoning is right; taking the deployment-wide writer lock to get it was the wrong tool. Every member fetches a path before proving, so path reads serialized across the whole deployment, each holding a connection for 33 round-trips while excluding writers it never conflicts with.

Instead: read the root, walk, read it again. Equal roots mean nothing was published across the walk and the path is a real snapshot. One retry, because a second collision means genuine write pressure rather than an unlucky interleaving, and spinning here is what the lock was for.

ponytail: optimistic, not locked — two extra root reads per path against a deployment-wide serialization point. If issuance ever becomes hot enough that the retry fires routinely, the upgrade is a repeatable-read transaction, not the advisory lock.

func (*ZK) ProveClaim

func (z *ZK) ProveClaim(ctx context.Context, db orm.DB, req MembershipRequest) error

ProveClaim @notice Verifies membership for the current session and records its claim.

@dev Recurring claims persist for the session. One-shot claims are atomically burned in Postgres and exist only in the request-local holder populated through ProofSink.

func (*ZK) RevokeCredential

func (z *ZK) RevokeCredential(ctx context.Context, db orm.DB, index uint32) error

RevokeCredential @notice Removes one active leaf and publishes the resulting root atomically.

func (*ZK) RevokeCredentialsForUser

func (z *ZK) RevokeCredentialsForUser(ctx context.Context, db orm.DB, userID string) error

RevokeCredentialsForUser @notice Revokes every live credential linked to a named user.

func (*ZK) RootGrace

func (z *ZK) RootGrace() time.Duration

RootGrace @notice Reports the configured revocation-latency allowance.

func (*ZK) VerifyKnowledge

func (z *ZK) VerifyKnowledge(ctx context.Context, db orm.DB, req KnowledgeRequest) error

VerifyKnowledge @notice Verifies step-up, timestamps MFA and rotates the session credential.

@dev Three phases, and the split is the point. Phase 1 reads and phase 2 verifies, both outside any transaction: pairing arithmetic queued behind a semaphore used to run with an open transaction, a pooled connection and the challenge row's lock all held, so CPU pressure on an unauthenticated endpoint turned into connection-pool exhaustion for every other query. Phase 3 opens the transaction and burns the challenge with the single-use UPDATE, which is still the only thing deciding freshness. Do not collapse these back together for atomicity — phases 1 and 2 write nothing.

Jump to

Keyboard shortcuts

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