cpace

package module
v0.1.2 Latest Latest
Warning

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

Go to latest
Published: May 9, 2026 License: BSD-3-Clause Imports: 11 Imported by: 0

README

CPace for Go

OpenSSF Scorecard OpenSSF Best Practices

This repository implements draft-irtf-cfrg-cpace-21 for the CPACE-RISTR255-SHA512 suite only.

Status: auditable draft implementation. This code has not had independent cryptographic review and is not production-ready.

Current work is release readiness, not new policy design. The public API and package-profile choices are frozen for review unless a new finding reopens a decision. Before any production-readiness claim, the release bar in docs/security-assessment.md must be satisfied.

The public API exposes only an initiator-responder flow with mandatory explicit key confirmation:

  1. Start returns initiator state and message A.
  2. Respond consumes message A and returns responder state and message B.
  3. Initiator.Finish verifies message B and returns message C plus Session.
  4. Responder.Finish verifies message C and returns Session.

Respond returning success is not authentication. Only successful Initiator.Finish and Responder.Finish calls return authenticated sessions. Treat message B as an unauthenticated protocol message until the finish calls complete.

This module is a package-specific cpace-go profile over draft-21. It builds CI internally from the draft version, suite, roles, initiator ID, responder ID, and caller context. It also owns its binary wire framing; applications should treat message bytes as opaque and versioned by this module. The current wire format prefix byte is 0xc1.

Both sides must configure the same role orientation: InitiatorID is the party that called Start, and ResponderID is the party that called Respond, on both machines. A common integration bug is for each side to put its own identity first; that makes the CI inputs differ and confirmation fails. Do not use only globally hardcoded role labels such as "client" and "server" for all users or deployments. Bind stable, application-meaningful party identities into these fields.

Provide a fresh, non-secret SessionID agreed by both parties for every session. Empty session IDs are rejected by default because they weaken replay and transcript separation properties; that failure wraps both ErrInvalidInput and ErrEmptySessionID. AllowEmptySessionID exists only for draft-21 compatibility tests or deliberately compatible profiles that accept the weaker empty-sid behavior. If an outer protocol negotiates PAKE versions, ciphersuites, or whether CPace is used at all, that negotiation needs its own downgrade protection; this package does not authenticate negotiation it never sees. See docs/integration-guidance.md for outer negotiation and downgrade-protection guidance.

Initiator.Finish and Responder.Finish are single-use calls. Passing a malformed message or a message that fails confirmation consumes the state and requires restarting the exchange.

Session.TranscriptID is the draft CPaceSidOutput for the confirmed CPace transcript, not a complete channel binding for outer protocol negotiation. Session.Export derives deterministic, domain-separated application key material from the confirmed ISK. It is not a source of fresh randomness; use specific labels and contexts for each exported key purpose. Session.Close performs best-effort cleanup of the session key material and makes future Export calls fail with ErrSessionClosed; non-secret metadata accessors remain available after close. Session.PeerAssociatedData returns the exact peer AD bound into the confirmed exchange. Session.PeerID returns the caller-configured peer identity that was bound into CI and confirmed by the exchange; it is not parsed from peer-controlled wire data.

Scalar randomness is always drawn from Go's crypto/rand.Reader. Callers do not provide randomness to Start or Respond.

Input and wire fields have package-owned per-field caps: passwords and party IDs are limited to 4 KiB, context and session IDs to 1 KiB, and associated data to 64 KiB. These are not aggregate message-size limits. Associated data should bind protocol context, not carry large payloads; represent large external artifacts with a digest, Merkle root, exporter, or other fixed-size commitment.

Validation

This repository uses Taskfile.yml as the local validation facade:

task quick
task check
task check:changed
task docs:check
task bench
FUZZTIME=30s PARALLEL=2 task fuzz
FUZZ_RACE=0 GOMAXPROCS=4 FUZZTIME=8m PARALLEL=2 task fuzz

Fuzz targets live in .github/fuzz-targets.json. GitHub-hosted pull-request CI is intentionally light because it runs untrusted fork code: code changes run go test ./..., and Markdown-only PRs run docs validation without setting up Go. Scheduled hosted lanes run govulncheck, advisory gosec, and a 5-minute-per-target fuzz regression pass as background signal. Run the full local gate, longer maintainer-machine fuzzing, vulnerability scan, and advisory gosec scan locally before release-oriented changes. The default fuzz lane keeps the race detector on for smoke runs; use FUZZ_RACE=0 for longer campaigns after task check has already covered race-instrumented tests. See docs/ci-policy.md for the hosted and self-hosted runner threat model.

Release-readiness work should record exact evidence: commit SHA, command or workflow, duration for fuzzing, target count, and residual risks. Dependency review evidence lives in docs/dependency-review.md; fuzz campaign evidence lives in docs/fuzz-evidence.md; security/spec audit evidence lives in docs/security-spec-audit.md; Capslock capability-analysis evidence lives in docs/capslock-report.md; local benchmark guidance lives in docs/performance.md. External reviewer scope and review questions are summarized in docs/external-review-handoff.md; security boundaries are summarized in docs/threat-model.md. Downstream release verification instructions live in docs/release-verification.md. Project governance, security-gate, and VEX policies live in docs/governance.md, docs/security-gates.md, and docs/vex.md.

initiator, msgA, err := cpace.Start(initCfg)
responder, msgB, err := cpace.Respond(respCfg, msgA)
msgC, initSession, err := initiator.Finish(msgB)
respSession, err := responder.Finish(msgC)
key, err := initSession.Export([]byte("application key"), nil, 32)

Release policy: keep tags in the v0.x range until independent review is complete and the release bar in docs/security-assessment.md is satisfied. Use docs/release-checklist.md for future release candidates. See CONTRIBUTING.md before opening public issues or pull requests; commits must include DCO signoffs.

License

BSD-3-Clause. See LICENSE.

Documentation

Overview

Package cpace implements an auditable draft-irtf-cfrg-cpace-21 CPACE-RISTR255-SHA512 initiator-responder flow.

This module is an Internet-Draft implementation. It is not independently audited and must not be treated as production-ready cryptographic software.

The public API intentionally exposes only an initiator-responder flow with mandatory explicit key confirmation. A session is returned only after both sides have confirmed possession of the same intermediate session key. Respond success alone is not authentication.

Scalar randomness always comes from crypto/rand.Reader; the package does not accept caller-supplied randomness through the public API. Config and wire fields have package-owned per-field size caps; associated data is capped at 64 KiB and smaller identity/context fields are capped more tightly.

Config.SessionID must be non-empty by default. Config.AllowEmptySessionID is only for draft-21 compatibility tests or deliberately compatible profiles that accept weaker replay and transcript separation.

Session.Close performs best-effort cleanup of session key material and makes future Export calls fail. PeerAssociatedData and PeerID expose copied, non-secret metadata bound into the confirmed exchange.

Both parties must use the same role orientation: InitiatorID identifies the Start side, and ResponderID identifies the Respond side. Applications that negotiate PAKE versions, suites, or protocol modes outside this package must provide their own downgrade protection for that outer negotiation.

Example
package main

import (
	"bytes"
	"fmt"

	"github.com/the-sarge/cpace"
)

func main() {
	common := cpace.Config{
		Password:    []byte("correct horse battery staple"),
		InitiatorID: []byte("client@example"),
		ResponderID: []byte("server@example"),
		Context:     []byte("example protocol v1"),
		SessionID:   []byte("session-1234"),
	}

	initCfg := common
	initCfg.AssociatedData = []byte("client hello")
	initiator, msgA, err := cpace.Start(initCfg)
	if err != nil {
		panic(err)
	}

	respCfg := common
	respCfg.AssociatedData = []byte("server hello")
	responder, msgB, err := cpace.Respond(respCfg, msgA)
	if err != nil {
		panic(err)
	}

	msgC, initSession, err := initiator.Finish(msgB)
	if err != nil {
		panic(err)
	}
	respSession, err := responder.Finish(msgC)
	if err != nil {
		panic(err)
	}
	defer func() {
		if err := initSession.Close(); err != nil {
			panic(err)
		}
	}()
	defer func() {
		if err := respSession.Close(); err != nil {
			panic(err)
		}
	}()

	initKey, _ := initSession.Export([]byte("application key"), nil, 32)
	respKey, _ := respSession.Export([]byte("application key"), nil, 32)
	fmt.Println(bytes.Equal(initKey, respKey))
	fmt.Println(bytes.Equal(initSession.TranscriptID(), respSession.TranscriptID()))
}
Output:
true
true

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrInvalidInput reports invalid local configuration or parameters.
	ErrInvalidInput = errors.New("cpace: invalid input")

	// ErrEmptySessionID reports an empty Config.SessionID without the explicit
	// AllowEmptySessionID compatibility opt-in. The returned error also wraps
	// ErrInvalidInput.
	ErrEmptySessionID = errors.New("cpace: empty session id")

	// ErrRandomness reports randomness-related failures, including random
	// source read failures and repeated unusable scalar samples.
	ErrRandomness = errors.New("cpace: randomness failure")

	// ErrMessage reports malformed or unexpected wire messages.
	ErrMessage = errors.New("cpace: invalid message")

	// ErrStateUsed reports an attempt to reuse a single-use protocol state.
	ErrStateUsed = errors.New("cpace: state already used")

	// ErrSessionClosed reports an attempt to export key material from a closed
	// Session.
	ErrSessionClosed = errors.New("cpace: session closed")

	// ErrAbort reports a draft abort condition such as an invalid point or
	// neutral-element Diffie-Hellman result.
	ErrAbort = errors.New("cpace: protocol abort")

	// ErrConfirmationFailed reports failed explicit key confirmation.
	ErrConfirmationFailed = errors.New("cpace: key confirmation failed")
)

Functions

This section is empty.

Types

type Config

type Config struct {
	Password            []byte
	InitiatorID         []byte
	ResponderID         []byte
	Context             []byte
	SessionID           []byte
	AssociatedData      []byte
	AllowEmptySessionID bool
}

Config contains the local inputs for one CPace role.

Password, InitiatorID, and ResponderID must be non-empty. Context and AssociatedData may be empty. Both parties must use the same role orientation: InitiatorID is the party that called Start, and ResponderID is the party that called Respond. SessionID must be a fresh, non-secret, parties-agree-on value for every session. Empty SessionID values are rejected by default because they weaken replay and transcript separation properties. Set AllowEmptySessionID only for draft-21 compatibility tests or profiles that have deliberately accepted the weaker empty-sid behavior. Scalar randomness always comes from crypto/rand.Reader. The AssociatedData field is ADa for Start and ADb for Respond. Field lengths are capped at 4 KiB for Password and IDs, 1 KiB for Context and SessionID, and 64 KiB for AssociatedData. Inputs exceeding these caps are rejected before copying; accepted byte slices are copied by Start and Respond before use.

type Initiator

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

Initiator is a single-use initiator state returned by Start.

func Start

func Start(cfg Config) (*Initiator, []byte, error)

Start creates initiator state and message A.

func (*Initiator) Finish

func (i *Initiator) Finish(messageB []byte) ([]byte, *Session, error)

Finish consumes message B, verifies the responder confirmation tag, and returns message C plus an authenticated session. The initiator state is consumed even when message parsing or confirmation fails.

Example (ConfirmationFailure)
package main

import (
	"errors"
	"fmt"

	"github.com/the-sarge/cpace"
)

func main() {
	common := exampleConfig("example-confirmation-failure")
	initCfg := common
	initCfg.AssociatedData = []byte("client hello")
	initiator, msgA, err := cpace.Start(initCfg)
	if err != nil {
		panic(err)
	}

	respCfg := common
	respCfg.Context = []byte("different protocol context")
	respCfg.AssociatedData = []byte("server hello")
	_, msgB, err := cpace.Respond(respCfg, msgA)
	if err != nil {
		panic(err)
	}

	_, _, err = initiator.Finish(msgB)
	fmt.Println(errors.Is(err, cpace.ErrConfirmationFailed))
}

func exampleConfig(sessionID string) cpace.Config {
	return cpace.Config{
		Password:    []byte("correct horse battery staple"),
		InitiatorID: []byte("client@example"),
		ResponderID: []byte("server@example"),
		Context:     []byte("example protocol v1"),
		SessionID:   []byte(sessionID),
	}
}
Output:
true

type Responder

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

Responder is a single-use responder state returned by Respond.

func Respond

func Respond(cfg Config, messageA []byte) (*Responder, []byte, error)

Respond consumes message A, creates responder state, and returns message B. Message B includes the responder's explicit key-confirmation tag. A nil error from Respond does not authenticate the initiator; authentication is established only by successful Finish calls.

func (*Responder) Finish

func (r *Responder) Finish(messageC []byte) (*Session, error)

Finish consumes message C, verifies the initiator confirmation tag, and returns an authenticated session. The responder state is consumed even when message parsing or confirmation fails.

type Session

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

Session is an explicitly confirmed CPace session. Copies of a Session share the same close state and secret key material.

func (*Session) Close

func (s *Session) Close() error

Close releases the secret key material held by the Session. Close is idempotent. It performs best-effort in-memory key cleanup, but Go does not provide guaranteed secure memory erasure and the runtime or compiler may make additional copies. Non-secret metadata such as TranscriptID, PeerAssociatedData, and PeerID remains available after Close.

Example
package main

import (
	"errors"
	"fmt"

	"github.com/the-sarge/cpace"
)

func main() {
	initSession, respSession, err := exampleConfirmedSessions("example-close")
	if err != nil {
		panic(err)
	}
	defer closeExampleSession(respSession)

	if err := initSession.Close(); err != nil {
		panic(err)
	}
	_, err = initSession.Export([]byte("application key"), nil, 32)

	fmt.Println(errors.Is(err, cpace.ErrSessionClosed))
	fmt.Println(len(initSession.TranscriptID()) > 0)
}

func exampleConfig(sessionID string) cpace.Config {
	return cpace.Config{
		Password:    []byte("correct horse battery staple"),
		InitiatorID: []byte("client@example"),
		ResponderID: []byte("server@example"),
		Context:     []byte("example protocol v1"),
		SessionID:   []byte(sessionID),
	}
}

func exampleConfirmedSessions(sessionID string) (*cpace.Session, *cpace.Session, error) {
	common := exampleConfig(sessionID)

	initCfg := common
	initCfg.AssociatedData = []byte("client hello")
	initiator, msgA, err := cpace.Start(initCfg)
	if err != nil {
		return nil, nil, err
	}

	respCfg := common
	respCfg.AssociatedData = []byte("server hello")
	responder, msgB, err := cpace.Respond(respCfg, msgA)
	if err != nil {
		return nil, nil, err
	}

	msgC, initSession, err := initiator.Finish(msgB)
	if err != nil {
		return nil, nil, err
	}
	respSession, err := responder.Finish(msgC)
	if err != nil {
		_ = initSession.Close()
		return nil, nil, err
	}
	return initSession, respSession, nil
}

func closeExampleSession(session *cpace.Session) {
	if err := session.Close(); err != nil {
		panic(err)
	}
}
Output:
true
true

func (*Session) Export

func (s *Session) Export(label, context []byte, length int) ([]byte, error)

Export derives deterministic application key material from the confirmed ISK using HKDF-SHA512. The label and context are prefix-free encoded into HKDF info. Export output is not fresh randomness or a randomness pool; use separate, domain-specific labels and contexts for each application purpose.

Example
package main

import (
	"bytes"
	"fmt"

	"github.com/the-sarge/cpace"
)

func main() {
	initSession, respSession, err := exampleConfirmedSessions("example-export")
	if err != nil {
		panic(err)
	}
	defer closeExampleSession(initSession)
	defer closeExampleSession(respSession)

	trafficKey, err := initSession.Export([]byte("traffic key"), []byte("initiator to responder"), 32)
	if err != nil {
		panic(err)
	}
	headerKey, err := initSession.Export([]byte("header key"), []byte("initiator to responder"), 32)
	if err != nil {
		panic(err)
	}
	peerTrafficKey, err := respSession.Export([]byte("traffic key"), []byte("initiator to responder"), 32)
	if err != nil {
		panic(err)
	}

	fmt.Println(len(trafficKey))
	fmt.Println(bytes.Equal(trafficKey, headerKey))
	fmt.Println(bytes.Equal(trafficKey, peerTrafficKey))
}

func exampleConfig(sessionID string) cpace.Config {
	return cpace.Config{
		Password:    []byte("correct horse battery staple"),
		InitiatorID: []byte("client@example"),
		ResponderID: []byte("server@example"),
		Context:     []byte("example protocol v1"),
		SessionID:   []byte(sessionID),
	}
}

func exampleConfirmedSessions(sessionID string) (*cpace.Session, *cpace.Session, error) {
	common := exampleConfig(sessionID)

	initCfg := common
	initCfg.AssociatedData = []byte("client hello")
	initiator, msgA, err := cpace.Start(initCfg)
	if err != nil {
		return nil, nil, err
	}

	respCfg := common
	respCfg.AssociatedData = []byte("server hello")
	responder, msgB, err := cpace.Respond(respCfg, msgA)
	if err != nil {
		return nil, nil, err
	}

	msgC, initSession, err := initiator.Finish(msgB)
	if err != nil {
		return nil, nil, err
	}
	respSession, err := responder.Finish(msgC)
	if err != nil {
		_ = initSession.Close()
		return nil, nil, err
	}
	return initSession, respSession, nil
}

func closeExampleSession(session *cpace.Session) {
	if err := session.Close(); err != nil {
		panic(err)
	}
}
Output:
32
false
true

func (*Session) PeerAssociatedData

func (s *Session) PeerAssociatedData() []byte

PeerAssociatedData returns the peer associated data that was bound into the confirmed exchange. The returned slice is a copy and remains available after Close.

func (*Session) PeerID

func (s *Session) PeerID() []byte

PeerID returns the caller-configured peer identity that was bound into CI and confirmed by the completed exchange. The value is copied from Config; it is not parsed from peer-controlled wire data. The returned slice is a copy and remains available after Close.

func (*Session) TranscriptID

func (s *Session) TranscriptID() []byte

TranscriptID returns the draft CPaceSidOutput value for the confirmed initiator-responder CPace transcript. It is not a complete channel binding for any outer version, suite, or application-protocol negotiation. TranscriptID remains available after Close.

Example
package main

import (
	"bytes"
	"fmt"

	"github.com/the-sarge/cpace"
)

func main() {
	initSession, respSession, err := exampleConfirmedSessions("example-transcript")
	if err != nil {
		panic(err)
	}
	defer closeExampleSession(initSession)
	defer closeExampleSession(respSession)

	fmt.Println(len(initSession.TranscriptID()))
	fmt.Println(bytes.Equal(initSession.TranscriptID(), respSession.TranscriptID()))
}

func exampleConfig(sessionID string) cpace.Config {
	return cpace.Config{
		Password:    []byte("correct horse battery staple"),
		InitiatorID: []byte("client@example"),
		ResponderID: []byte("server@example"),
		Context:     []byte("example protocol v1"),
		SessionID:   []byte(sessionID),
	}
}

func exampleConfirmedSessions(sessionID string) (*cpace.Session, *cpace.Session, error) {
	common := exampleConfig(sessionID)

	initCfg := common
	initCfg.AssociatedData = []byte("client hello")
	initiator, msgA, err := cpace.Start(initCfg)
	if err != nil {
		return nil, nil, err
	}

	respCfg := common
	respCfg.AssociatedData = []byte("server hello")
	responder, msgB, err := cpace.Respond(respCfg, msgA)
	if err != nil {
		return nil, nil, err
	}

	msgC, initSession, err := initiator.Finish(msgB)
	if err != nil {
		return nil, nil, err
	}
	respSession, err := responder.Finish(msgC)
	if err != nil {
		_ = initSession.Close()
		return nil, nil, err
	}
	return initSession, respSession, nil
}

func closeExampleSession(session *cpace.Session) {
	if err := session.Close(); err != nil {
		panic(err)
	}
}
Output:
64
true

type Suite

type Suite byte

Suite identifies a CPace ciphersuite in this package's wire framing.

const (
	// DraftVersion identifies the CPace Internet-Draft revision implemented by
	// this package.
	DraftVersion = "draft-irtf-cfrg-cpace-21"

	// SuiteCPaceRistretto255SHA512 is the only suite implemented by v1 of this
	// package.
	SuiteCPaceRistretto255SHA512 Suite = 0x01
)

Jump to

Keyboard shortcuts

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