From 88b4145cde6cd54695061787d97ad4b84ed1e2ca Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Tue, 14 Jul 2026 00:39:29 +0200 Subject: [PATCH 1/6] spike(identity): prototype cross-server brokerage --- cli/internal/identitybroker/README.md | 169 +++++++ cli/internal/identitybroker/broker.go | 246 ++++++++++ cli/internal/identitybroker/builders.go | 90 ++++ cli/internal/identitybroker/canonical.go | 134 ++++++ cli/internal/identitybroker/crypto.go | 72 +++ cli/internal/identitybroker/doc.go | 8 + .../identitybroker/integration_test.go | 335 ++++++++++++++ cli/internal/identitybroker/protocol.go | 221 +++++++++ cli/internal/identitybroker/protocol_test.go | 420 ++++++++++++++++++ cli/internal/identitybroker/trust.go | 49 ++ cli/internal/identitybroker/validate.go | 143 ++++++ cli/internal/identitybroker/verifier.go | 241 ++++++++++ 12 files changed, 2128 insertions(+) create mode 100644 cli/internal/identitybroker/README.md create mode 100644 cli/internal/identitybroker/broker.go create mode 100644 cli/internal/identitybroker/builders.go create mode 100644 cli/internal/identitybroker/canonical.go create mode 100644 cli/internal/identitybroker/crypto.go create mode 100644 cli/internal/identitybroker/doc.go create mode 100644 cli/internal/identitybroker/integration_test.go create mode 100644 cli/internal/identitybroker/protocol.go create mode 100644 cli/internal/identitybroker/protocol_test.go create mode 100644 cli/internal/identitybroker/trust.go create mode 100644 cli/internal/identitybroker/validate.go create mode 100644 cli/internal/identitybroker/verifier.go diff --git a/cli/internal/identitybroker/README.md b/cli/internal/identitybroker/README.md new file mode 100644 index 0000000000..8d3003520b --- /dev/null +++ b/cli/internal/identitybroker/README.md @@ -0,0 +1,169 @@ +# Cross-server identity brokerage proof of concept + +This package tests whether independent Chatto servers can broker a portable +identity group without asking users to create, transfer, or recover an identity +key. It is an isolated protocol experiment, not a production Chatto feature. + +The intended reader is a Chatto maintainer evaluating the protocol and its +security boundary before choosing public APIs, persisted protobufs, or user +experience. + +## Result + +The PoC demonstrates a linear identity group whose membership can be verified +by a clean client from public certificates: + +- Two accounts on distinct servers create the group. +- Every later member is approved by its target server and two current members + on distinct servers. +- Each participating server signs the exact group, account, role, challenge, + issuance, and expiry fields. +- A disposable client ceremony key binds intercepted artifacts to one ceremony. + The key is discarded after completion and is not part of durable client + state. +- A verifier reconstructs membership from credential references rather than + trusting certificate order or mutable profile fields. +- Twenty accounts require nineteen unique certificates: one genesis plus + eighteen membership certificates. + +The HTTP integration test places every broker behind a distinct loopback +origin. A new trust store then discovers the public server keys, fetches the +completed certificates, and reconstructs all twenty members without receiving +any ceremony private key. + +## Protocol artifacts + +### Challenge + +A server issues a short-lived challenge only after authenticating its local +account. The challenge binds an opaque account ID, certificate kind, approval +role, nonce, and expiry. + +### Statement + +The client constructs one canonical statement containing all participants and +their challenges. The statement uses stable opaque user IDs and exact server +origins; usernames, display names, and email addresses are not signed identity +inputs. + +The PoC uses an explicitly length-prefixed binary encoding for signatures. It +does not rely on JSON field order or protobuf serialization behavior. + +### Ceremony request + +The client signs the statement with a newly generated disposable Ed25519 key. +Every server verifies this signature before issuing its approval. An attacker +who intercepts an unsigned or partially signed request cannot alter it or +complete it through another client. + +### Server approval + +Each server signs its exact account and role over the statement ID. A complete +certificate contains one approval for every named participant and no duplicate +approvals. + +### Membership credential + +The genesis certificate grants one credential to each founder. A later +membership certificate grants one credential to its target and identifies the +two existing credentials that sponsored it. Sponsor credentials must: + +- Belong to the named sponsor accounts. +- Come from distinct server origins. +- Be active when the membership was issued. +- Belong to the same identity group. + +Credential references form a directed acyclic dependency graph. Unresolved or +cyclic sponsorship is rejected. + +### Revocation + +A member can issue a signed revocation for its own credential. Revocation is +durable and does not expire. The verifier excludes the credential after the +revocation time and rejects memberships sponsored after a sponsor was revoked. + +The PoC does not yet define quorum removal, group recovery, or group epochs. + +## Security boundary + +The PoC establishes the following limited claim: + +> A complete certificate records one client-mediated ceremony in which all +> named servers approved their named local accounts and roles. + +It protects against: + +- Modification of a server origin, account ID, role, group ID, nonce, issuance, + or expiry after signing. +- Reusing one challenge for a different statement. +- Completing an intercepted ceremony without its disposable client key. +- Extending a group through one existing malicious server alone, because two + current member credentials on distinct origins must sponsor a new member. +- Treating certificate input order as authority. + +It does not protect against: + +- Two colluding current member servers. +- A compromised client during an active ceremony. +- Two compromised sponsor accounts on otherwise honest servers. +- A server lying about how it authenticated its own local account. +- Correlation after a certificate has been disclosed. +- Stale status when servers are unavailable. +- Server signing-key loss, rotation, or origin migration. + +The certificate proves account-control continuity as attested by Chatto +servers. It does not prove a legal identity, a unique human, employment, or +control of an email address. + +## Production mapping + +The PoC keeps all state in memory so protocol changes remain cheap. A production +implementation must follow Chatto's existing storage boundaries. + +| PoC state | Production boundary | +| --- | --- | +| Unconsumed challenge | `RUNTIME_STATE` record with a short per-key TTL | +| Idempotent issued approval | `RUNTIME_STATE` until the ceremony completes or expires | +| Completed genesis or membership | Durable fact on the local user aggregate in `EVT` | +| Revocation | Durable fact on the local user aggregate in `EVT` | +| Current identity-group view | Replay-safe projection derived from `EVT` | +| Server signing private key | Dedicated protected key lifecycle, deliberately undecided | +| Server signing public keys | Origin-bound discovery metadata with historical rotation support | + +Production writes would require JetStream optimistic concurrency over the local +user identity-link event family, projection catch-up before returning, and +idempotent finalization. Peer notification would be a retryable post-commit side +effect, not the durable source of truth. + +No production implementation should copy the PoC's process-local challenge, +approval, or certificate maps. + +## Open questions + +The PoC deliberately leaves these decisions open: + +- How server signing keys are generated, protected, backed up, rotated, and + retained for historical verification. +- Whether membership certificates expire, renew automatically, or use explicit + group epochs. +- How a user recovers when fewer than two existing member accounts remain + accessible. +- Whether a certificate is private, visible to DM peers, server-member-visible, + or public. +- How clients obtain the supporting certificate chain without publishing the + complete identity group. +- Whether approval should use a disposable signature key, PKCE verifier, or a + standardized sender-constrained authorization mechanism. +- Which ConnectRPC package and stability tier should expose future operations. +- How mixed-version servers negotiate protocol and canonical-encoding versions. + +These questions should be resolved through an ADR/FDR before the experiment is +integrated into Chatto's public API or durable core protobufs. + +## Verification + +Run the focused suite from `cli/`: + +```sh +mise x -- go test ./internal/identitybroker -count=1 -timeout 60s +``` diff --git a/cli/internal/identitybroker/broker.go b/cli/internal/identitybroker/broker.go new file mode 100644 index 0000000000..cc87e43b1f --- /dev/null +++ b/cli/internal/identitybroker/broker.go @@ -0,0 +1,246 @@ +package identitybroker + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "sync" + "time" +) + +const DefaultChallengeTTL = 5 * time.Minute + +type issuedApproval struct { + statementID string + approval Approval +} + +// Broker is an in-memory protocol actor for the PoC. Its challenge and +// certificate repositories intentionally model the operations that would move +// to RUNTIME_STATE and EVT, but this type must not be used as production state. +type Broker struct { + mu sync.Mutex + + origin string + keyID string + publicKey ed25519.PublicKey + privateKey ed25519.PrivateKey + + challenges map[string]Challenge + approvals map[string]issuedApproval + certificates map[string]Certificate +} + +// NewBroker creates an isolated in-memory protocol actor and signing identity. +func NewBroker(origin string) (*Broker, error) { + normalizedOrigin, err := NormalizeOrigin(origin) + if err != nil { + return nil, err + } + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate broker signing key: %w", err) + } + digest := sha256.Sum256(publicKey) + return &Broker{ + origin: normalizedOrigin, + keyID: base64.RawURLEncoding.EncodeToString(digest[:12]), + publicKey: publicKey, + privateKey: privateKey, + challenges: map[string]Challenge{}, + approvals: map[string]issuedApproval{}, + certificates: map[string]Certificate{}, + }, nil +} + +// Origin returns the broker's canonical server origin. +func (b *Broker) Origin() string { + return b.origin +} + +// Discovery returns the broker's public origin-bound signing metadata. +func (b *Broker) Discovery() DiscoveryKey { + return DiscoveryKey{ + Protocol: ProtocolVersion, + Origin: b.origin, + KeyID: b.keyID, + PublicKey: append([]byte(nil), b.publicKey...), + } +} + +// IssueChallenge creates one short-lived authorization checkpoint for a local +// authenticated account. +func (b *Broker) IssueChallenge(account Account, kind, role string, now time.Time) (Challenge, error) { + if account.Origin != b.origin { + return Challenge{}, fmt.Errorf("%w: account belongs to %s, not %s", ErrChallengeMismatch, account.Origin, b.origin) + } + if err := account.Validate(); err != nil { + return Challenge{}, err + } + if !validRoleForKind(kind, role) { + return Challenge{}, fmt.Errorf("%w: role %q is not valid for %q", ErrChallengeMismatch, role, kind) + } + id, err := NewOpaqueID(24) + if err != nil { + return Challenge{}, err + } + nonce, err := NewOpaqueID(32) + if err != nil { + return Challenge{}, err + } + challenge := Challenge{ + ID: id, + Nonce: nonce, + Kind: kind, + Role: role, + Account: account, + ExpiresAt: now.Add(DefaultChallengeTTL).Unix(), + } + b.mu.Lock() + b.challenges[id] = challenge + b.mu.Unlock() + return challenge, nil +} + +// Approve validates and consumes the authenticated account's challenge, or +// returns the same approval for an idempotent retry of the same statement. +func (b *Broker) Approve(authenticated Account, request CeremonyRequest, now time.Time) (Approval, error) { + if authenticated.Origin != b.origin { + return Approval{}, ErrChallengeMismatch + } + if err := VerifyCeremony(request); err != nil { + return Approval{}, err + } + + participant, ok := participantForAccount(request.Statement.Participants, authenticated) + if !ok { + return Approval{}, ErrChallengeMismatch + } + statementID, err := StatementID(request.Statement) + if err != nil { + return Approval{}, err + } + + b.mu.Lock() + defer b.mu.Unlock() + if issued, exists := b.approvals[participant.ChallengeID]; exists { + if issued.statementID != statementID { + return Approval{}, ErrApprovalAlreadyIssued + } + return cloneApproval(issued.approval), nil + } + challenge, exists := b.challenges[participant.ChallengeID] + if !exists { + return Approval{}, ErrChallengeNotFound + } + if challenge.ExpiresAt < now.Unix() { + return Approval{}, ErrChallengeExpired + } + if challenge.Kind != request.Statement.Kind || challenge.Role != participant.Role || challenge.Account != authenticated || challenge.Nonce != participant.Nonce { + return Approval{}, ErrChallengeMismatch + } + if request.Statement.IssuedAt > now.Add(time.Minute).Unix() || request.Statement.IssuedAt < now.Add(-DefaultChallengeTTL).Unix() { + return Approval{}, fmt.Errorf("%w: statement issuance is outside the ceremony window", ErrChallengeMismatch) + } + + signature, err := signApproval(request.Statement, participant.Role, authenticated, b.privateKey) + if err != nil { + return Approval{}, err + } + approval := Approval{ + Origin: b.origin, + KeyID: b.keyID, + Role: participant.Role, + Account: authenticated, + Signature: signature, + } + b.approvals[participant.ChallengeID] = issuedApproval{statementID: statementID, approval: approval} + delete(b.challenges, participant.ChallengeID) + return cloneApproval(approval), nil +} + +// Finalize verifies and stores a complete certificate involving this broker. +func (b *Broker) Finalize(certificate Certificate, verifier *Verifier, supporting []Certificate, now time.Time) error { + statementID, err := StatementID(certificate.Request.Statement) + if err != nil { + return err + } + if _, err := verifier.Reconstruct(append(append([]Certificate(nil), supporting...), certificate), now); err != nil { + return err + } + if !statementContainsOrigin(certificate.Request.Statement, b.origin) { + return fmt.Errorf("%w: certificate does not involve this broker", ErrInvalidArtifact) + } + b.mu.Lock() + defer b.mu.Unlock() + if existing, ok := b.certificates[statementID]; ok { + existingID, existingErr := StatementID(existing.Request.Statement) + if existingErr != nil || existingID != statementID { + return fmt.Errorf("%w: conflicting finalized certificate", ErrInvalidArtifact) + } + return nil + } + b.certificates[statementID] = cloneCertificate(certificate) + return nil +} + +// Certificates returns defensive copies of the broker's finalized artifacts. +func (b *Broker) Certificates() []Certificate { + b.mu.Lock() + defer b.mu.Unlock() + result := make([]Certificate, 0, len(b.certificates)) + for _, certificate := range b.certificates { + result = append(result, cloneCertificate(certificate)) + } + return result +} + +func participantForAccount(participants []Participant, account Account) (Participant, bool) { + for _, participant := range participants { + if participant.Account == account { + return participant, true + } + } + return Participant{}, false +} + +func validRoleForKind(kind, role string) bool { + switch kind { + case KindGenesis: + return role == RoleFounder + case KindMembership: + return role == RoleTarget || role == RoleSponsor + case KindRevocation: + return role == RoleMember + default: + return false + } +} + +func statementContainsOrigin(statement Statement, origin string) bool { + for _, participant := range statement.Participants { + if participant.Account.Origin == origin { + return true + } + } + return false +} + +func cloneApproval(approval Approval) Approval { + approval.Signature = append([]byte(nil), approval.Signature...) + return approval +} + +func cloneCertificate(certificate Certificate) Certificate { + certificate.Request.Statement.CeremonyPublicKey = append([]byte(nil), certificate.Request.Statement.CeremonyPublicKey...) + certificate.Request.Statement.Sponsors = append([]SponsorRef(nil), certificate.Request.Statement.Sponsors...) + certificate.Request.Statement.Participants = append([]Participant(nil), certificate.Request.Statement.Participants...) + certificate.Request.CeremonySignature = append([]byte(nil), certificate.Request.CeremonySignature...) + certificate.Approvals = append([]Approval(nil), certificate.Approvals...) + for i := range certificate.Approvals { + certificate.Approvals[i] = cloneApproval(certificate.Approvals[i]) + } + return certificate +} diff --git a/cli/internal/identitybroker/builders.go b/cli/internal/identitybroker/builders.go new file mode 100644 index 0000000000..f2c0ee1398 --- /dev/null +++ b/cli/internal/identitybroker/builders.go @@ -0,0 +1,90 @@ +package identitybroker + +import ( + "crypto/ed25519" + "fmt" + "time" +) + +// NewGenesisStatement constructs the first certificate statement for two +// founders on distinct server origins. +func NewGenesisStatement(groupID string, founders []Challenge, ceremonyPublicKey ed25519.PublicKey, issuedAt time.Time, validFor time.Duration) (Statement, error) { + if len(founders) != 2 { + return Statement{}, fmt.Errorf("%w: genesis requires two founder challenges", ErrInvalidArtifact) + } + participants := make([]Participant, 0, 2) + for _, challenge := range founders { + if challenge.Kind != KindGenesis || challenge.Role != RoleFounder { + return Statement{}, ErrChallengeMismatch + } + participants = append(participants, participantFromChallenge(challenge)) + } + statement := Statement{ + Version: ProtocolVersion, + Kind: KindGenesis, + GroupID: groupID, + Participants: participants, + CeremonyPublicKey: append([]byte(nil), ceremonyPublicKey...), + IssuedAt: issuedAt.Unix(), + ExpiresAt: issuedAt.Add(validFor).Unix(), + } + return statement, validateAndReturn(statement) +} + +// NewMembershipStatement constructs a group join sponsored by two existing +// credentials on distinct server origins. +func NewMembershipStatement(groupID string, target Challenge, sponsors []Challenge, sponsorRefs []SponsorRef, ceremonyPublicKey ed25519.PublicKey, issuedAt time.Time, validFor time.Duration) (Statement, error) { + if target.Kind != KindMembership || target.Role != RoleTarget || len(sponsors) != 2 || len(sponsorRefs) != 2 { + return Statement{}, ErrInsufficientSponsors + } + participants := []Participant{participantFromChallenge(target)} + for _, challenge := range sponsors { + if challenge.Kind != KindMembership || challenge.Role != RoleSponsor { + return Statement{}, ErrChallengeMismatch + } + participants = append(participants, participantFromChallenge(challenge)) + } + statement := Statement{ + Version: ProtocolVersion, + Kind: KindMembership, + GroupID: groupID, + Subject: target.Account, + Sponsors: append([]SponsorRef(nil), sponsorRefs...), + Participants: participants, + CeremonyPublicKey: append([]byte(nil), ceremonyPublicKey...), + IssuedAt: issuedAt.Unix(), + ExpiresAt: issuedAt.Add(validFor).Unix(), + } + return statement, validateAndReturn(statement) +} + +// NewRevocationStatement constructs a member's permanent self-revocation. +func NewRevocationStatement(groupID, credentialID string, member Challenge, ceremonyPublicKey ed25519.PublicKey, issuedAt time.Time) (Statement, error) { + if member.Kind != KindRevocation || member.Role != RoleMember { + return Statement{}, ErrChallengeMismatch + } + statement := Statement{ + Version: ProtocolVersion, + Kind: KindRevocation, + GroupID: groupID, + Subject: member.Account, + RevokedCredentialID: credentialID, + Participants: []Participant{participantFromChallenge(member)}, + CeremonyPublicKey: append([]byte(nil), ceremonyPublicKey...), + IssuedAt: issuedAt.Unix(), + } + return statement, validateAndReturn(statement) +} + +func participantFromChallenge(challenge Challenge) Participant { + return Participant{ + Role: challenge.Role, + Account: challenge.Account, + ChallengeID: challenge.ID, + Nonce: challenge.Nonce, + } +} + +func validateAndReturn(statement Statement) error { + return validateStatement(statement) +} diff --git a/cli/internal/identitybroker/canonical.go b/cli/internal/identitybroker/canonical.go new file mode 100644 index 0000000000..79820c3291 --- /dev/null +++ b/cli/internal/identitybroker/canonical.go @@ -0,0 +1,134 @@ +package identitybroker + +import ( + "bytes" + "encoding/binary" + "fmt" +) + +const maxCanonicalFieldLength = 1 << 20 + +type canonicalEncoder struct { + bytes.Buffer +} + +func (e *canonicalEncoder) string(value string) error { + return e.bytes([]byte(value)) +} + +func (e *canonicalEncoder) bytes(value []byte) error { + if len(value) > maxCanonicalFieldLength { + return fmt.Errorf("%w: canonical field exceeds %d bytes", ErrInvalidArtifact, maxCanonicalFieldLength) + } + if err := binary.Write(&e.Buffer, binary.BigEndian, uint32(len(value))); err != nil { + return err + } + _, err := e.Write(value) + return err +} + +func (e *canonicalEncoder) int64(value int64) error { + return binary.Write(&e.Buffer, binary.BigEndian, value) +} + +func (e *canonicalEncoder) account(account Account) error { + if err := e.string(account.Origin); err != nil { + return err + } + return e.string(account.UserID) +} + +func canonicalStatement(statement Statement) ([]byte, error) { + if err := validateStatement(statement); err != nil { + return nil, err + } + + participants := append([]Participant(nil), statement.Participants...) + sponsors := append([]SponsorRef(nil), statement.Sponsors...) + sortParticipants(participants) + sortSponsors(sponsors) + + var e canonicalEncoder + for _, value := range []string{ + "chatto-identity-broker-statement-v1", + statement.Version, + statement.Kind, + statement.GroupID, + } { + if err := e.string(value); err != nil { + return nil, err + } + } + if err := e.account(statement.Subject); err != nil { + return nil, err + } + if err := e.string(statement.RevokedCredentialID); err != nil { + return nil, err + } + if err := e.int64(int64(len(sponsors))); err != nil { + return nil, err + } + for _, sponsor := range sponsors { + if err := e.account(sponsor.Account); err != nil { + return nil, err + } + if err := e.string(sponsor.CredentialID); err != nil { + return nil, err + } + } + if err := e.int64(int64(len(participants))); err != nil { + return nil, err + } + for _, participant := range participants { + if err := e.string(participant.Role); err != nil { + return nil, err + } + if err := e.account(participant.Account); err != nil { + return nil, err + } + if err := e.string(participant.ChallengeID); err != nil { + return nil, err + } + if err := e.string(participant.Nonce); err != nil { + return nil, err + } + } + if err := e.bytes(statement.CeremonyPublicKey); err != nil { + return nil, err + } + if err := e.int64(statement.IssuedAt); err != nil { + return nil, err + } + if err := e.int64(statement.ExpiresAt); err != nil { + return nil, err + } + return e.Bytes(), nil +} + +func approvalSigningBytes(statementID, role string, account Account) ([]byte, error) { + var e canonicalEncoder + for _, value := range []string{ + "chatto-identity-broker-approval-v1", + statementID, + role, + } { + if err := e.string(value); err != nil { + return nil, err + } + } + if err := e.account(account); err != nil { + return nil, err + } + return e.Bytes(), nil +} + +func ceremonySigningBytes(statementID string) ([]byte, error) { + var e canonicalEncoder + if err := e.string("chatto-identity-broker-ceremony-v1"); err != nil { + return nil, err + } + if err := e.string(statementID); err != nil { + return nil, err + } + return e.Bytes(), nil +} diff --git a/cli/internal/identitybroker/crypto.go b/cli/internal/identitybroker/crypto.go new file mode 100644 index 0000000000..528e38103a --- /dev/null +++ b/cli/internal/identitybroker/crypto.go @@ -0,0 +1,72 @@ +package identitybroker + +import ( + "crypto/ed25519" + "fmt" +) + +// SignCeremony binds a statement to one disposable ceremony key. +func SignCeremony(statement Statement, privateKey ed25519.PrivateKey) (CeremonyRequest, error) { + if len(privateKey) != ed25519.PrivateKeySize { + return CeremonyRequest{}, fmt.Errorf("%w: ceremony private key has length %d", ErrInvalidArtifact, len(privateKey)) + } + publicKey := privateKey.Public().(ed25519.PublicKey) + if !publicKey.Equal(ed25519.PublicKey(statement.CeremonyPublicKey)) { + return CeremonyRequest{}, fmt.Errorf("%w: ceremony private key does not match statement", ErrInvalidArtifact) + } + statementID, err := StatementID(statement) + if err != nil { + return CeremonyRequest{}, err + } + payload, err := ceremonySigningBytes(statementID) + if err != nil { + return CeremonyRequest{}, err + } + return CeremonyRequest{ + Statement: statement, + CeremonySignature: ed25519.Sign(privateKey, payload), + }, nil +} + +// VerifyCeremony validates the disposable client's proof of possession. +func VerifyCeremony(request CeremonyRequest) error { + statementID, err := StatementID(request.Statement) + if err != nil { + return err + } + payload, err := ceremonySigningBytes(statementID) + if err != nil { + return err + } + if !ed25519.Verify(ed25519.PublicKey(request.Statement.CeremonyPublicKey), payload, request.CeremonySignature) { + return ErrInvalidSignature + } + return nil +} + +func signApproval(statement Statement, role string, account Account, privateKey ed25519.PrivateKey) ([]byte, error) { + statementID, err := StatementID(statement) + if err != nil { + return nil, err + } + payload, err := approvalSigningBytes(statementID, role, account) + if err != nil { + return nil, err + } + return ed25519.Sign(privateKey, payload), nil +} + +func verifyApproval(statement Statement, approval Approval, publicKey ed25519.PublicKey) error { + statementID, err := StatementID(statement) + if err != nil { + return err + } + payload, err := approvalSigningBytes(statementID, approval.Role, approval.Account) + if err != nil { + return err + } + if !ed25519.Verify(publicKey, payload, approval.Signature) { + return ErrInvalidSignature + } + return nil +} diff --git a/cli/internal/identitybroker/doc.go b/cli/internal/identitybroker/doc.go new file mode 100644 index 0000000000..686503e122 --- /dev/null +++ b/cli/internal/identitybroker/doc.go @@ -0,0 +1,8 @@ +// Package identitybroker contains an isolated protocol proof of concept for +// user-mediated identity groups spanning independent Chatto servers. +// +// It deliberately has no production API, persistence, or configuration +// integration. The package exists to exercise signed protocol artifacts and +// their adversarial verification before Chatto commits to durable protobuf or +// public API shapes. +package identitybroker diff --git a/cli/internal/identitybroker/integration_test.go b/cli/internal/identitybroker/integration_test.go new file mode 100644 index 0000000000..8fb1640552 --- /dev/null +++ b/cli/internal/identitybroker/integration_test.go @@ -0,0 +1,335 @@ +package identitybroker + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "reflect" + "slices" + "strings" + "testing" + "time" +) + +type httpBrokerServer struct { + server *httptest.Server + broker *Broker + account Account + token string + verifier *Verifier +} + +type challengeHTTPRequest struct { + Kind string `json:"kind"` + Role string `json:"role"` +} + +type finalizeHTTPRequest struct { + Certificate Certificate `json:"certificate"` + Supporting []Certificate `json:"supporting"` + Now int64 `json:"now"` +} + +func newHTTPBrokerServer(t *testing.T, userID string) *httpBrokerServer { + t.Helper() + testServer := httptest.NewUnstartedServer(nil) + origin := "http://" + testServer.Listener.Addr().String() + broker, err := NewBroker(origin) + if err != nil { + t.Fatalf("NewBroker: %v", err) + } + token, err := NewOpaqueID(24) + if err != nil { + t.Fatalf("NewOpaqueID: %v", err) + } + h := &httpBrokerServer{ + server: testServer, + broker: broker, + account: Account{Origin: broker.Origin(), UserID: userID}, + token: token, + } + testServer.Config.Handler = h.handler() + testServer.Start() + if testServer.URL != broker.Origin() { + t.Fatalf("test server origin = %q, broker origin = %q", testServer.URL, broker.Origin()) + } + t.Cleanup(testServer.Close) + return h +} + +func (h *httpBrokerServer) handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /.well-known/chatto-identity-broker", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, h.broker.Discovery()) + }) + mux.HandleFunc("POST /challenge", func(w http.ResponseWriter, r *http.Request) { + if !h.authenticate(r) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthenticated"}) + return + } + var request challengeHTTPRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + challenge, err := h.broker.IssueChallenge(h.account, request.Kind, request.Role, testNow) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, challenge) + }) + mux.HandleFunc("POST /approve", func(w http.ResponseWriter, r *http.Request) { + if !h.authenticate(r) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthenticated"}) + return + } + var request CeremonyRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + approval, err := h.broker.Approve(h.account, request, testNow.Add(time.Minute)) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, approval) + }) + mux.HandleFunc("POST /finalize", func(w http.ResponseWriter, r *http.Request) { + if !h.authenticate(r) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthenticated"}) + return + } + var request finalizeHTTPRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if h.verifier == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "verifier unavailable"}) + return + } + if err := h.broker.Finalize(request.Certificate, h.verifier, request.Supporting, time.Unix(request.Now, 0)); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"finalized": true}) + }) + mux.HandleFunc("GET /certificates", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, h.broker.Certificates()) + }) + return mux +} + +func (h *httpBrokerServer) authenticate(r *http.Request) bool { + return r.Header.Get("Authorization") == "Bearer "+h.token +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func httpJSON[T any](t *testing.T, method, rawURL, token string, requestBody any) T { + t.Helper() + var body io.Reader + if requestBody != nil { + encoded, err := json.Marshal(requestBody) + if err != nil { + t.Fatalf("marshal %s: %v", rawURL, err) + } + body = bytes.NewReader(encoded) + } + request, err := http.NewRequest(method, rawURL, body) + if err != nil { + t.Fatalf("NewRequest(%s): %v", rawURL, err) + } + if token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + if requestBody != nil { + request.Header.Set("Content-Type", "application/json") + } + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatalf("%s %s: %v", method, rawURL, err) + } + defer response.Body.Close() + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + t.Fatalf("read %s: %v", rawURL, err) + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + t.Fatalf("%s %s status = %d: %s", method, rawURL, response.StatusCode, strings.TrimSpace(string(responseBytes))) + } + var result T + if err := json.Unmarshal(responseBytes, &result); err != nil { + t.Fatalf("decode %s: %v", rawURL, err) + } + return result +} + +func discoverHTTPBrokers(t *testing.T, brokers []*httpBrokerServer) *TrustStore { + t.Helper() + trust := NewTrustStore() + for _, broker := range brokers { + discovery := httpJSON[DiscoveryKey](t, http.MethodGet, broker.server.URL+"/.well-known/chatto-identity-broker", "", nil) + if err := trust.Add(discovery); err != nil { + t.Fatalf("trust.Add(%s): %v", broker.server.URL, err) + } + } + return trust +} + +func httpChallenge(t *testing.T, broker *httpBrokerServer, kind, role string) Challenge { + t.Helper() + return httpJSON[Challenge](t, http.MethodPost, broker.server.URL+"/challenge", broker.token, challengeHTTPRequest{Kind: kind, Role: role}) +} + +func httpApprovals(t *testing.T, request CeremonyRequest, brokers ...*httpBrokerServer) []Approval { + t.Helper() + approvals := make([]Approval, 0, len(brokers)) + for _, broker := range brokers { + approvals = append(approvals, httpJSON[Approval](t, http.MethodPost, broker.server.URL+"/approve", broker.token, request)) + } + return approvals +} + +func httpFinalize(t *testing.T, broker *httpBrokerServer, certificate Certificate, supporting []Certificate, now time.Time) { + t.Helper() + httpJSON[map[string]bool](t, http.MethodPost, broker.server.URL+"/finalize", broker.token, finalizeHTTPRequest{ + Certificate: certificate, + Supporting: supporting, + Now: now.Unix(), + }) +} + +func httpGenesis(t *testing.T, first, second *httpBrokerServer, now time.Time) (Certificate, string) { + t.Helper() + groupID, err := NewOpaqueID(32) + if err != nil { + t.Fatal(err) + } + publicKey, privateKey, err := NewCeremonyKey() + if err != nil { + t.Fatal(err) + } + statement, err := NewGenesisStatement(groupID, []Challenge{ + httpChallenge(t, first, KindGenesis, RoleFounder), + httpChallenge(t, second, KindGenesis, RoleFounder), + }, publicKey, now, 72*time.Hour) + if err != nil { + t.Fatal(err) + } + request, err := SignCeremony(statement, privateKey) + if err != nil { + t.Fatal(err) + } + return Certificate{Request: request, Approvals: httpApprovals(t, request, first, second)}, groupID +} + +func httpMembership(t *testing.T, groupID string, target, sponsorA, sponsorB *httpBrokerServer, refs []SponsorRef, now time.Time) Certificate { + t.Helper() + publicKey, privateKey, err := NewCeremonyKey() + if err != nil { + t.Fatal(err) + } + statement, err := NewMembershipStatement( + groupID, + httpChallenge(t, target, KindMembership, RoleTarget), + []Challenge{ + httpChallenge(t, sponsorA, KindMembership, RoleSponsor), + httpChallenge(t, sponsorB, KindMembership, RoleSponsor), + }, + refs, + publicKey, + now, + 72*time.Hour, + ) + if err != nil { + t.Fatal(err) + } + request, err := SignCeremony(statement, privateKey) + if err != nil { + t.Fatal(err) + } + return Certificate{Request: request, Approvals: httpApprovals(t, request, target, sponsorA, sponsorB)} +} + +func TestHTTPBrokerScalesToTwentyServersAndCleanClient(t *testing.T) { + const serverCount = 20 + brokers := make([]*httpBrokerServer, 0, serverCount) + for i := range serverCount { + brokers = append(brokers, newHTTPBrokerServer(t, fmt.Sprintf("user-%02d", i))) + } + + serverTrust := discoverHTTPBrokers(t, brokers) + serverVerifier := NewVerifier(serverTrust) + for _, broker := range brokers { + broker.verifier = serverVerifier + } + + genesis, groupID := httpGenesis(t, brokers[0], brokers[1], testNow) + httpFinalize(t, brokers[0], genesis, nil, testNow) + httpFinalize(t, brokers[1], genesis, nil, testNow) + genesisID, err := StatementID(genesis.Request.Statement) + if err != nil { + t.Fatal(err) + } + sponsorRefs := []SponsorRef{ + {Account: brokers[0].account, CredentialID: CredentialID(genesisID, brokers[0].account)}, + {Account: brokers[1].account, CredentialID: CredentialID(genesisID, brokers[1].account)}, + } + + for i := 2; i < serverCount; i++ { + issuedAt := testNow.Add(time.Duration(i) * time.Second) + membership := httpMembership(t, groupID, brokers[i], brokers[0], brokers[1], sponsorRefs, issuedAt) + for _, participant := range []*httpBrokerServer{brokers[i], brokers[0], brokers[1]} { + httpFinalize(t, participant, membership, []Certificate{genesis}, issuedAt) + } + } + + // A new device builds a new trust store and fetches only public artifacts; + // it receives no ceremony private key or prior client state. + cleanTrust := discoverHTTPBrokers(t, brokers) + cleanVerifier := NewVerifier(cleanTrust) + var bundle []Certificate + for _, broker := range brokers { + certificates := httpJSON[[]Certificate](t, http.MethodGet, broker.server.URL+"/certificates", "", nil) + bundle = append(bundle, certificates...) + } + group, err := cleanVerifier.Reconstruct(bundle, testNow.Add(time.Minute)) + if err != nil { + t.Fatalf("clean Reconstruct: %v", err) + } + if got := len(group.MemberAccounts()); got != serverCount { + t.Fatalf("member count = %d, want %d", got, serverCount) + } + + uniqueCertificates := map[string]struct{}{} + for _, certificate := range bundle { + id, err := StatementID(certificate.Request.Statement) + if err != nil { + t.Fatal(err) + } + uniqueCertificates[id] = struct{}{} + } + if got, want := len(uniqueCertificates), serverCount-1; got != want { + t.Fatalf("unique certificate count = %d, want linear %d", got, want) + } + + wantAccounts := make([]Account, 0, serverCount) + for _, broker := range brokers { + wantAccounts = append(wantAccounts, broker.account) + } + // Both lists use the protocol's stable account ordering. + slices.SortFunc(wantAccounts, func(a, b Account) int { return strings.Compare(a.key(), b.key()) }) + if got := group.MemberAccounts(); !reflect.DeepEqual(got, wantAccounts) { + t.Fatalf("clean members = %#v, want %#v", got, wantAccounts) + } +} diff --git a/cli/internal/identitybroker/protocol.go b/cli/internal/identitybroker/protocol.go new file mode 100644 index 0000000000..7a07080a79 --- /dev/null +++ b/cli/internal/identitybroker/protocol.go @@ -0,0 +1,221 @@ +package identitybroker + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "net" + "net/url" + "slices" + "strings" +) + +const ( + ProtocolVersion = "chatto-identity-broker-poc-v1" + + KindGenesis = "genesis" + KindMembership = "membership" + KindRevocation = "revocation" + + RoleFounder = "founder" + RoleTarget = "target" + RoleSponsor = "sponsor" + RoleMember = "member" +) + +var ( + ErrInvalidArtifact = errors.New("identity broker: invalid artifact") + ErrInvalidSignature = errors.New("identity broker: invalid signature") + ErrChallengeNotFound = errors.New("identity broker: challenge not found") + ErrChallengeExpired = errors.New("identity broker: challenge expired") + ErrChallengeMismatch = errors.New("identity broker: challenge mismatch") + ErrApprovalAlreadyIssued = errors.New("identity broker: approval already issued for different statement") + ErrCertificateIncomplete = errors.New("identity broker: certificate is incomplete") + ErrInsufficientSponsors = errors.New("identity broker: membership requires two current sponsors on distinct servers") +) + +// Account is one server-scoped Chatto account. UserID is an opaque stable ID; +// mutable usernames and profile fields are intentionally not identity inputs. +type Account struct { + Origin string `json:"origin"` + UserID string `json:"user_id"` +} + +func (a Account) Validate() error { + normalizedOrigin, err := NormalizeOrigin(a.Origin) + if err != nil { + return fmt.Errorf("%w: account origin: %v", ErrInvalidArtifact, err) + } + if normalizedOrigin != a.Origin { + return fmt.Errorf("%w: account origin is not canonical", ErrInvalidArtifact) + } + if strings.TrimSpace(a.UserID) == "" { + return fmt.Errorf("%w: account user id is empty", ErrInvalidArtifact) + } + return nil +} + +func (a Account) key() string { + return a.Origin + "\x00" + a.UserID +} + +// Challenge is a short-lived, single-server authorization checkpoint. A +// production implementation would persist it in RUNTIME_STATE with a TTL. +type Challenge struct { + ID string `json:"id"` + Nonce string `json:"nonce"` + Kind string `json:"kind"` + Role string `json:"role"` + Account Account `json:"account"` + ExpiresAt int64 `json:"expires_at"` +} + +// Participant binds one expected server approval to the exact challenge that +// the authenticated account authorized. +type Participant struct { + Role string `json:"role"` + Account Account `json:"account"` + ChallengeID string `json:"challenge_id"` + Nonce string `json:"nonce"` +} + +// SponsorRef proves which already-issued group credential authorized a new +// member. Two distinct sponsor origins prevent one malicious existing server +// from extending a group by itself. +type SponsorRef struct { + Account Account `json:"account"` + CredentialID string `json:"credential_id"` +} + +// Statement is the canonical signed payload for all PoC certificate kinds. +// Exact validation depends on Kind. +type Statement struct { + Version string `json:"version"` + Kind string `json:"kind"` + GroupID string `json:"group_id"` + Subject Account `json:"subject,omitempty"` + RevokedCredentialID string `json:"revoked_credential_id,omitempty"` + Sponsors []SponsorRef `json:"sponsors,omitempty"` + Participants []Participant `json:"participants"` + CeremonyPublicKey []byte `json:"ceremony_public_key"` + IssuedAt int64 `json:"issued_at"` + ExpiresAt int64 `json:"expires_at,omitempty"` +} + +// CeremonyRequest is presented independently to every participating server. +// The disposable ceremony signature prevents intercepted requests from being +// completed by a different client. +type CeremonyRequest struct { + Statement Statement `json:"statement"` + CeremonySignature []byte `json:"ceremony_signature"` +} + +// Approval is one origin's signature for one account/role in a statement. +// The server attests that it freshly authenticated the named local account. +type Approval struct { + Origin string `json:"origin"` + KeyID string `json:"key_id"` + Role string `json:"role"` + Account Account `json:"account"` + Signature []byte `json:"signature"` +} + +// Certificate combines a ceremony request with every required server +// approval. It contains no bearer token or persistent client secret. +type Certificate struct { + Request CeremonyRequest `json:"request"` + Approvals []Approval `json:"approvals"` +} + +// DiscoveryKey is the origin-bound public signing metadata used by verifiers. +type DiscoveryKey struct { + Protocol string `json:"protocol"` + Origin string `json:"origin"` + KeyID string `json:"key_id"` + PublicKey []byte `json:"public_key"` +} + +// NewOpaqueID returns a base64url-encoded identifier backed by cryptographic +// randomness. +func NewOpaqueID(byteCount int) (string, error) { + if byteCount < 16 { + return "", fmt.Errorf("%w: opaque ids require at least 16 random bytes", ErrInvalidArtifact) + } + b := make([]byte, byteCount) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate opaque id: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// NewCeremonyKey creates a disposable proof-of-possession key for one ceremony. +func NewCeremonyKey() (ed25519.PublicKey, ed25519.PrivateKey, error) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("generate ceremony key: %w", err) + } + return publicKey, privateKey, nil +} + +// StatementID hashes the validated canonical statement bytes. +func StatementID(statement Statement) (string, error) { + payload, err := canonicalStatement(statement) + if err != nil { + return "", err + } + digest := sha256.Sum256(payload) + return base64.RawURLEncoding.EncodeToString(digest[:]), nil +} + +// CredentialID derives one account-specific credential ID from a certificate. +func CredentialID(certificateID string, account Account) string { + h := sha256.New() + h.Write([]byte("chatto-identity-broker-credential-v1\x00")) + h.Write([]byte(certificateID)) + h.Write([]byte{0}) + h.Write([]byte(account.Origin)) + h.Write([]byte{0}) + h.Write([]byte(account.UserID)) + return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) +} + +// NormalizeOrigin returns an origin containing only a lowercase scheme and +// host. Production origins require HTTPS; HTTP is accepted only for loopback +// protocol tests. +func NormalizeOrigin(raw string) (string, error) { + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("parse origin: %w", err) + } + if u.Scheme == "" || u.Host == "" || u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { + return "", errors.New("origin must contain only scheme and host") + } + scheme := strings.ToLower(u.Scheme) + host := strings.ToLower(u.Host) + if scheme != "https" { + hostname := u.Hostname() + ip := net.ParseIP(hostname) + if scheme != "http" || (hostname != "localhost" && (ip == nil || !ip.IsLoopback())) { + return "", errors.New("origin must use https except for loopback tests") + } + } + return scheme + "://" + host, nil +} + +func sortParticipants(participants []Participant) { + slices.SortFunc(participants, func(a, b Participant) int { + if c := strings.Compare(a.Role, b.Role); c != 0 { + return c + } + return strings.Compare(a.Account.key(), b.Account.key()) + }) +} + +func sortSponsors(sponsors []SponsorRef) { + slices.SortFunc(sponsors, func(a, b SponsorRef) int { + return strings.Compare(a.Account.key(), b.Account.key()) + }) +} diff --git a/cli/internal/identitybroker/protocol_test.go b/cli/internal/identitybroker/protocol_test.go new file mode 100644 index 0000000000..6d92be3da3 --- /dev/null +++ b/cli/internal/identitybroker/protocol_test.go @@ -0,0 +1,420 @@ +package identitybroker + +import ( + "crypto/ed25519" + "errors" + "reflect" + "testing" + "time" +) + +var testNow = time.Unix(1_800_000_000, 0).UTC() + +type testMember struct { + broker *Broker + account Account +} + +func newTestMember(t *testing.T, origin, userID string) testMember { + t.Helper() + broker, err := NewBroker(origin) + if err != nil { + t.Fatalf("NewBroker(%q): %v", origin, err) + } + return testMember{broker: broker, account: Account{Origin: broker.Origin(), UserID: userID}} +} + +func trustMembers(t *testing.T, members ...testMember) *TrustStore { + t.Helper() + trust := NewTrustStore() + for _, member := range members { + if err := trust.Add(member.broker.Discovery()); err != nil { + t.Fatalf("trust.Add(%s): %v", member.account.Origin, err) + } + } + return trust +} + +func issueChallenge(t *testing.T, member testMember, kind, role string, now time.Time) Challenge { + t.Helper() + challenge, err := member.broker.IssueChallenge(member.account, kind, role, now) + if err != nil { + t.Fatalf("IssueChallenge(%s, %s): %v", kind, role, err) + } + return challenge +} + +func signWithMembers(t *testing.T, statement Statement, privateKey ed25519.PrivateKey, now time.Time, members ...testMember) Certificate { + t.Helper() + request, err := SignCeremony(statement, privateKey) + if err != nil { + t.Fatalf("SignCeremony: %v", err) + } + certificate := Certificate{Request: request} + for _, member := range members { + approval, err := member.broker.Approve(member.account, request, now) + if err != nil { + t.Fatalf("Approve(%s): %v", member.account.Origin, err) + } + certificate.Approvals = append(certificate.Approvals, approval) + } + return certificate +} + +func makeGenesis(t *testing.T, first, second testMember, now time.Time) (Certificate, string) { + t.Helper() + groupID, err := NewOpaqueID(32) + if err != nil { + t.Fatalf("NewOpaqueID: %v", err) + } + publicKey, privateKey, err := NewCeremonyKey() + if err != nil { + t.Fatalf("NewCeremonyKey: %v", err) + } + statement, err := NewGenesisStatement(groupID, []Challenge{ + issueChallenge(t, first, KindGenesis, RoleFounder, now), + issueChallenge(t, second, KindGenesis, RoleFounder, now), + }, publicKey, now, 24*time.Hour) + if err != nil { + t.Fatalf("NewGenesisStatement: %v", err) + } + return signWithMembers(t, statement, privateKey, now, first, second), groupID +} + +func makeMembership(t *testing.T, groupID string, target, sponsorA, sponsorB testMember, refs []SponsorRef, now time.Time) Certificate { + t.Helper() + publicKey, privateKey, err := NewCeremonyKey() + if err != nil { + t.Fatalf("NewCeremonyKey: %v", err) + } + statement, err := NewMembershipStatement( + groupID, + issueChallenge(t, target, KindMembership, RoleTarget, now), + []Challenge{ + issueChallenge(t, sponsorA, KindMembership, RoleSponsor, now), + issueChallenge(t, sponsorB, KindMembership, RoleSponsor, now), + }, + refs, + publicKey, + now, + 24*time.Hour, + ) + if err != nil { + t.Fatalf("NewMembershipStatement: %v", err) + } + return signWithMembers(t, statement, privateKey, now, target, sponsorA, sponsorB) +} + +func founderRefs(t *testing.T, genesis Certificate, founders ...testMember) []SponsorRef { + t.Helper() + certificateID, err := StatementID(genesis.Request.Statement) + if err != nil { + t.Fatalf("StatementID(genesis): %v", err) + } + refs := make([]SponsorRef, 0, len(founders)) + for _, founder := range founders { + refs = append(refs, SponsorRef{ + Account: founder.account, + CredentialID: CredentialID(certificateID, founder.account), + }) + } + return refs +} + +func TestVerifierReconstructsGenesisAndMembership(t *testing.T) { + first := newTestMember(t, "https://one.example", "user-one") + second := newTestMember(t, "https://two.example", "user-two") + third := newTestMember(t, "https://three.example", "user-three") + verifier := NewVerifier(trustMembers(t, first, second, third)) + + genesis, groupID := makeGenesis(t, first, second, testNow) + membership := makeMembership(t, groupID, third, first, second, founderRefs(t, genesis, first, second), testNow.Add(time.Second)) + + group, err := verifier.Reconstruct([]Certificate{membership, genesis}, testNow.Add(2*time.Second)) + if err != nil { + t.Fatalf("Reconstruct: %v", err) + } + if group.ID != groupID { + t.Fatalf("group id = %q, want %q", group.ID, groupID) + } + if got := group.MemberAccounts(); !reflect.DeepEqual(got, []Account{first.account, third.account, second.account}) { + t.Fatalf("members = %#v", got) + } +} + +func TestCertificateRejectsTampering(t *testing.T) { + first := newTestMember(t, "https://one.example", "user-one") + second := newTestMember(t, "https://two.example", "user-two") + verifier := NewVerifier(trustMembers(t, first, second)) + genesis, _ := makeGenesis(t, first, second, testNow) + + tests := map[string]func(*Certificate){ + "group": func(c *Certificate) { c.Request.Statement.GroupID = "another-group" }, + "origin": func(c *Certificate) { + c.Request.Statement.Participants[0].Account.Origin = "https://attacker.example" + }, + "user": func(c *Certificate) { c.Request.Statement.Participants[0].Account.UserID = "attacker" }, + "nonce": func(c *Certificate) { c.Request.Statement.Participants[0].Nonce = "replacement" }, + "expiry": func(c *Certificate) { c.Request.Statement.ExpiresAt++ }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + tampered := cloneCertificate(genesis) + mutate(&tampered) + if _, err := verifier.VerifyCertificate(tampered); !errors.Is(err, ErrInvalidSignature) { + t.Fatalf("VerifyCertificate error = %v, want invalid signature", err) + } + }) + } +} + +func TestCertificateRequiresEveryOriginKeyToBeTrusted(t *testing.T) { + first := newTestMember(t, "https://one.example", "user-one") + second := newTestMember(t, "https://two.example", "user-two") + genesis, _ := makeGenesis(t, first, second, testNow) + verifier := NewVerifier(trustMembers(t, first)) + + if _, err := verifier.VerifyCertificate(genesis); !errors.Is(err, ErrInvalidSignature) { + t.Fatalf("VerifyCertificate error = %v, want invalid signature for unknown origin key", err) + } +} + +func TestCeremonyCannotBeCompletedWithAnotherPrivateKey(t *testing.T) { + first := newTestMember(t, "https://one.example", "user-one") + second := newTestMember(t, "https://two.example", "user-two") + groupID, err := NewOpaqueID(32) + if err != nil { + t.Fatal(err) + } + publicKey, _, err := NewCeremonyKey() + if err != nil { + t.Fatal(err) + } + _, attackerPrivateKey, err := NewCeremonyKey() + if err != nil { + t.Fatal(err) + } + statement, err := NewGenesisStatement(groupID, []Challenge{ + issueChallenge(t, first, KindGenesis, RoleFounder, testNow), + issueChallenge(t, second, KindGenesis, RoleFounder, testNow), + }, publicKey, testNow, time.Hour) + if err != nil { + t.Fatal(err) + } + if _, err := SignCeremony(statement, attackerPrivateKey); !errors.Is(err, ErrInvalidArtifact) { + t.Fatalf("SignCeremony error = %v, want mismatched ceremony key", err) + } +} + +func TestApprovalIsIdempotentButChallengeCannotChangeStatements(t *testing.T) { + first := newTestMember(t, "https://one.example", "user-one") + second := newTestMember(t, "https://two.example", "user-two") + groupID, err := NewOpaqueID(32) + if err != nil { + t.Fatal(err) + } + publicKey, privateKey, err := NewCeremonyKey() + if err != nil { + t.Fatal(err) + } + statement, err := NewGenesisStatement(groupID, []Challenge{ + issueChallenge(t, first, KindGenesis, RoleFounder, testNow), + issueChallenge(t, second, KindGenesis, RoleFounder, testNow), + }, publicKey, testNow, time.Hour) + if err != nil { + t.Fatal(err) + } + request, err := SignCeremony(statement, privateKey) + if err != nil { + t.Fatal(err) + } + approval, err := first.broker.Approve(first.account, request, testNow) + if err != nil { + t.Fatalf("first Approve: %v", err) + } + repeated, err := first.broker.Approve(first.account, request, testNow.Add(time.Second)) + if err != nil { + t.Fatalf("repeated Approve: %v", err) + } + if !reflect.DeepEqual(repeated, approval) { + t.Fatalf("repeated approval differs") + } + + statement.ExpiresAt++ + changed, err := SignCeremony(statement, privateKey) + if err != nil { + t.Fatal(err) + } + if _, err := first.broker.Approve(first.account, changed, testNow.Add(time.Second)); !errors.Is(err, ErrApprovalAlreadyIssued) { + t.Fatalf("changed Approve error = %v, want already issued", err) + } +} + +func TestExpiredChallengeIsRejected(t *testing.T) { + first := newTestMember(t, "https://one.example", "user-one") + second := newTestMember(t, "https://two.example", "user-two") + groupID, err := NewOpaqueID(32) + if err != nil { + t.Fatal(err) + } + publicKey, privateKey, err := NewCeremonyKey() + if err != nil { + t.Fatal(err) + } + statement, err := NewGenesisStatement(groupID, []Challenge{ + issueChallenge(t, first, KindGenesis, RoleFounder, testNow), + issueChallenge(t, second, KindGenesis, RoleFounder, testNow), + }, publicKey, testNow, time.Hour) + if err != nil { + t.Fatal(err) + } + request, err := SignCeremony(statement, privateKey) + if err != nil { + t.Fatal(err) + } + if _, err := first.broker.Approve(first.account, request, testNow.Add(DefaultChallengeTTL+time.Second)); !errors.Is(err, ErrChallengeExpired) { + t.Fatalf("Approve error = %v, want challenge expired", err) + } +} + +func TestOneExistingServerCannotSponsorMembershipTwice(t *testing.T) { + sponsor := newTestMember(t, "https://sponsor.example", "sponsor-one") + target := newTestMember(t, "https://target.example", "target") + secondSponsorAccount := Account{Origin: sponsor.account.Origin, UserID: "sponsor-two"} + secondChallenge, err := sponsor.broker.IssueChallenge(secondSponsorAccount, KindMembership, RoleSponsor, testNow) + if err != nil { + t.Fatal(err) + } + publicKey, _, err := NewCeremonyKey() + if err != nil { + t.Fatal(err) + } + _, err = NewMembershipStatement( + "group", + issueChallenge(t, target, KindMembership, RoleTarget, testNow), + []Challenge{ + issueChallenge(t, sponsor, KindMembership, RoleSponsor, testNow), + secondChallenge, + }, + []SponsorRef{ + {Account: sponsor.account, CredentialID: "credential-one"}, + {Account: secondSponsorAccount, CredentialID: "credential-two"}, + }, + publicKey, + testNow, + time.Hour, + ) + if !errors.Is(err, ErrInsufficientSponsors) && !errors.Is(err, ErrInvalidArtifact) { + t.Fatalf("NewMembershipStatement error = %v, want insufficient distinct sponsors", err) + } +} + +func TestRevocationRemovesMember(t *testing.T) { + first := newTestMember(t, "https://one.example", "user-one") + second := newTestMember(t, "https://two.example", "user-two") + third := newTestMember(t, "https://three.example", "user-three") + verifier := NewVerifier(trustMembers(t, first, second, third)) + genesis, groupID := makeGenesis(t, first, second, testNow) + membership := makeMembership(t, groupID, third, first, second, founderRefs(t, genesis, first, second), testNow.Add(time.Second)) + membershipID, err := StatementID(membership.Request.Statement) + if err != nil { + t.Fatal(err) + } + credentialID := CredentialID(membershipID, third.account) + publicKey, privateKey, err := NewCeremonyKey() + if err != nil { + t.Fatal(err) + } + revokedAt := testNow.Add(2 * time.Second) + statement, err := NewRevocationStatement( + groupID, + credentialID, + issueChallenge(t, third, KindRevocation, RoleMember, revokedAt), + publicKey, + revokedAt, + ) + if err != nil { + t.Fatal(err) + } + revocation := signWithMembers(t, statement, privateKey, revokedAt, third) + + group, err := verifier.Reconstruct([]Certificate{revocation, membership, genesis}, revokedAt.Add(time.Second)) + if err != nil { + t.Fatalf("Reconstruct: %v", err) + } + if got := group.MemberAccounts(); !reflect.DeepEqual(got, []Account{first.account, second.account}) { + t.Fatalf("members after revocation = %#v", got) + } +} + +func TestMembershipSponsoredAfterRevocationIsRejected(t *testing.T) { + first := newTestMember(t, "https://one.example", "user-one") + second := newTestMember(t, "https://two.example", "user-two") + third := newTestMember(t, "https://three.example", "user-three") + verifier := NewVerifier(trustMembers(t, first, second, third)) + genesis, groupID := makeGenesis(t, first, second, testNow) + genesisID, err := StatementID(genesis.Request.Statement) + if err != nil { + t.Fatal(err) + } + firstCredentialID := CredentialID(genesisID, first.account) + revokedAt := testNow.Add(time.Second) + publicKey, privateKey, err := NewCeremonyKey() + if err != nil { + t.Fatal(err) + } + revocationStatement, err := NewRevocationStatement( + groupID, + firstCredentialID, + issueChallenge(t, first, KindRevocation, RoleMember, revokedAt), + publicKey, + revokedAt, + ) + if err != nil { + t.Fatal(err) + } + revocation := signWithMembers(t, revocationStatement, privateKey, revokedAt, first) + membership := makeMembership(t, groupID, third, first, second, founderRefs(t, genesis, first, second), testNow.Add(2*time.Second)) + + if _, err := verifier.Reconstruct([]Certificate{membership, revocation, genesis}, testNow.Add(3*time.Second)); !errors.Is(err, ErrInsufficientSponsors) { + t.Fatalf("Reconstruct error = %v, want revoked sponsor rejection", err) + } +} + +func TestAccountCannotHoldTwoActiveGroupCredentials(t *testing.T) { + first := newTestMember(t, "https://one.example", "user-one") + second := newTestMember(t, "https://two.example", "user-two") + target := newTestMember(t, "https://target.example", "target") + verifier := NewVerifier(trustMembers(t, first, second, target)) + genesis, groupID := makeGenesis(t, first, second, testNow) + refs := founderRefs(t, genesis, first, second) + firstMembership := makeMembership(t, groupID, target, first, second, refs, testNow.Add(time.Second)) + secondMembership := makeMembership(t, groupID, target, first, second, refs, testNow.Add(2*time.Second)) + + if _, err := verifier.Reconstruct([]Certificate{genesis, firstMembership, secondMembership}, testNow.Add(3*time.Second)); !errors.Is(err, ErrInvalidArtifact) { + t.Fatalf("Reconstruct error = %v, want duplicate active credential rejection", err) + } +} + +func TestFinalizeIsIdempotentAndResumableAcrossBrokers(t *testing.T) { + first := newTestMember(t, "https://one.example", "user-one") + second := newTestMember(t, "https://two.example", "user-two") + verifier := NewVerifier(trustMembers(t, first, second)) + genesis, _ := makeGenesis(t, first, second, testNow) + + if err := first.broker.Finalize(genesis, verifier, nil, testNow); err != nil { + t.Fatalf("first finalize: %v", err) + } + if got := len(second.broker.Certificates()); got != 0 { + t.Fatalf("second certificates before resume = %d", got) + } + if err := second.broker.Finalize(genesis, verifier, nil, testNow); err != nil { + t.Fatalf("resumed second finalize: %v", err) + } + if err := second.broker.Finalize(genesis, verifier, nil, testNow); err != nil { + t.Fatalf("idempotent second finalize: %v", err) + } + if got := len(second.broker.Certificates()); got != 1 { + t.Fatalf("second certificates after resume = %d, want 1", got) + } +} diff --git a/cli/internal/identitybroker/trust.go b/cli/internal/identitybroker/trust.go new file mode 100644 index 0000000000..43b65cf5c4 --- /dev/null +++ b/cli/internal/identitybroker/trust.go @@ -0,0 +1,49 @@ +package identitybroker + +import ( + "crypto/ed25519" + "fmt" + "sync" +) + +// TrustStore contains public keys discovered for exact server origins. The PoC +// pins keys for the verifier's lifetime; production rotation remains a design +// question. +type TrustStore struct { + mu sync.RWMutex + keys map[string]map[string]ed25519.PublicKey +} + +// NewTrustStore creates an empty origin-key pin set. +func NewTrustStore() *TrustStore { + return &TrustStore{keys: map[string]map[string]ed25519.PublicKey{}} +} + +// Add pins one discovered public key to its exact origin and key ID. +func (s *TrustStore) Add(discovery DiscoveryKey) error { + origin, err := NormalizeOrigin(discovery.Origin) + if err != nil { + return err + } + if discovery.Protocol != ProtocolVersion || discovery.KeyID == "" || len(discovery.PublicKey) != ed25519.PublicKeySize { + return fmt.Errorf("%w: invalid discovery key", ErrInvalidArtifact) + } + s.mu.Lock() + defer s.mu.Unlock() + if s.keys[origin] == nil { + s.keys[origin] = map[string]ed25519.PublicKey{} + } + if existing := s.keys[origin][discovery.KeyID]; existing != nil && !existing.Equal(ed25519.PublicKey(discovery.PublicKey)) { + return fmt.Errorf("%w: key id %q changed for %s", ErrInvalidArtifact, discovery.KeyID, origin) + } + s.keys[origin][discovery.KeyID] = append(ed25519.PublicKey(nil), discovery.PublicKey...) + return nil +} + +func (s *TrustStore) key(origin, keyID string) (ed25519.PublicKey, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + keys := s.keys[origin] + key, ok := keys[keyID] + return append(ed25519.PublicKey(nil), key...), ok +} diff --git a/cli/internal/identitybroker/validate.go b/cli/internal/identitybroker/validate.go new file mode 100644 index 0000000000..fb8f800140 --- /dev/null +++ b/cli/internal/identitybroker/validate.go @@ -0,0 +1,143 @@ +package identitybroker + +import ( + "crypto/ed25519" + "fmt" + "slices" + "strings" +) + +func validateStatement(statement Statement) error { + if statement.Version != ProtocolVersion { + return fmt.Errorf("%w: unsupported protocol version %q", ErrInvalidArtifact, statement.Version) + } + if strings.TrimSpace(statement.GroupID) == "" { + return fmt.Errorf("%w: group id is empty", ErrInvalidArtifact) + } + if len(statement.CeremonyPublicKey) != ed25519.PublicKeySize { + return fmt.Errorf("%w: ceremony public key has length %d", ErrInvalidArtifact, len(statement.CeremonyPublicKey)) + } + if statement.IssuedAt <= 0 { + return fmt.Errorf("%w: issued-at is missing", ErrInvalidArtifact) + } + if statement.Kind != KindRevocation && statement.ExpiresAt <= statement.IssuedAt { + return fmt.Errorf("%w: credential expiry must follow issuance", ErrInvalidArtifact) + } + + participants := append([]Participant(nil), statement.Participants...) + sortParticipants(participants) + for i, participant := range participants { + if err := participant.Account.Validate(); err != nil { + return err + } + if participant.ChallengeID == "" || participant.Nonce == "" { + return fmt.Errorf("%w: participant challenge is incomplete", ErrInvalidArtifact) + } + if i > 0 && participant.Role == participants[i-1].Role && participant.Account == participants[i-1].Account { + return fmt.Errorf("%w: duplicate participant", ErrInvalidArtifact) + } + } + + switch statement.Kind { + case KindGenesis: + return validateGenesisStatement(statement) + case KindMembership: + return validateMembershipStatement(statement) + case KindRevocation: + return validateRevocationStatement(statement) + default: + return fmt.Errorf("%w: unknown statement kind %q", ErrInvalidArtifact, statement.Kind) + } +} + +func validateGenesisStatement(statement Statement) error { + if statement.Subject != (Account{}) || statement.RevokedCredentialID != "" || len(statement.Sponsors) != 0 { + return fmt.Errorf("%w: genesis contains membership or revocation fields", ErrInvalidArtifact) + } + if len(statement.Participants) != 2 { + return fmt.Errorf("%w: genesis requires two founders", ErrInvalidArtifact) + } + origins := map[string]struct{}{} + for _, participant := range statement.Participants { + if participant.Role != RoleFounder { + return fmt.Errorf("%w: genesis participant is not a founder", ErrInvalidArtifact) + } + origins[participant.Account.Origin] = struct{}{} + } + if len(origins) != 2 { + return fmt.Errorf("%w: founders must be on distinct servers", ErrInvalidArtifact) + } + return nil +} + +func validateMembershipStatement(statement Statement) error { + if err := statement.Subject.Validate(); err != nil { + return err + } + if statement.RevokedCredentialID != "" { + return fmt.Errorf("%w: membership revokes a credential", ErrInvalidArtifact) + } + if len(statement.Sponsors) != 2 || len(statement.Participants) != 3 { + return ErrInsufficientSponsors + } + + sponsors := append([]SponsorRef(nil), statement.Sponsors...) + sortSponsors(sponsors) + if sponsors[0].Account == sponsors[1].Account || sponsors[0].Account.Origin == sponsors[1].Account.Origin { + return ErrInsufficientSponsors + } + for _, sponsor := range sponsors { + if err := sponsor.Account.Validate(); err != nil { + return err + } + if sponsor.CredentialID == "" { + return fmt.Errorf("%w: sponsor credential id is empty", ErrInvalidArtifact) + } + } + + targets := 0 + participantSponsors := make([]Account, 0, 2) + seenOrigins := map[string]struct{}{} + for _, participant := range statement.Participants { + if _, exists := seenOrigins[participant.Account.Origin]; exists { + return fmt.Errorf("%w: every membership participant must use a distinct server", ErrInvalidArtifact) + } + seenOrigins[participant.Account.Origin] = struct{}{} + switch participant.Role { + case RoleTarget: + targets++ + if participant.Account != statement.Subject { + return fmt.Errorf("%w: target participant does not match subject", ErrInvalidArtifact) + } + case RoleSponsor: + participantSponsors = append(participantSponsors, participant.Account) + default: + return fmt.Errorf("%w: unexpected membership role %q", ErrInvalidArtifact, participant.Role) + } + } + if targets != 1 || len(participantSponsors) != 2 { + return ErrInsufficientSponsors + } + slices.SortFunc(participantSponsors, func(a, b Account) int { return strings.Compare(a.key(), b.key()) }) + if participantSponsors[0] != sponsors[0].Account || participantSponsors[1] != sponsors[1].Account { + return fmt.Errorf("%w: sponsor participants do not match sponsor references", ErrInvalidArtifact) + } + return nil +} + +func validateRevocationStatement(statement Statement) error { + if err := statement.Subject.Validate(); err != nil { + return err + } + if statement.RevokedCredentialID == "" || len(statement.Sponsors) != 0 || statement.ExpiresAt != 0 { + return fmt.Errorf("%w: malformed revocation", ErrInvalidArtifact) + } + if len(statement.Participants) != 1 { + return fmt.Errorf("%w: revocation requires the affected member", ErrInvalidArtifact) + } + participant := statement.Participants[0] + if participant.Role != RoleMember || participant.Account != statement.Subject { + return fmt.Errorf("%w: revocation participant does not match member", ErrInvalidArtifact) + } + return nil +} diff --git a/cli/internal/identitybroker/verifier.go b/cli/internal/identitybroker/verifier.go new file mode 100644 index 0000000000..b50c8dfcc0 --- /dev/null +++ b/cli/internal/identitybroker/verifier.go @@ -0,0 +1,241 @@ +package identitybroker + +import ( + "cmp" + "crypto/ed25519" + "fmt" + "slices" + "strings" + "time" +) + +// Credential is one account's independently verifiable membership in a group. +type Credential struct { + ID string + GroupID string + Account Account + CertificateID string + IssuedAt int64 + ExpiresAt int64 + RevokedAt int64 +} + +func (c Credential) activeAt(unixTime int64) bool { + return c.IssuedAt <= unixTime && unixTime < c.ExpiresAt && (c.RevokedAt == 0 || unixTime < c.RevokedAt) +} + +// Group is the verified view reconstructed from an unordered certificate +// bundle. Members contains only credentials active at the requested time. +type Group struct { + ID string + GenesisID string + Members map[string]Account + Credentials map[string]Credential +} + +type Verifier struct { + trust *TrustStore +} + +// NewVerifier creates a certificate verifier using pinned origin keys. +func NewVerifier(trust *TrustStore) *Verifier { + return &Verifier{trust: trust} +} + +type verifiedCertificate struct { + id string + certificate Certificate +} + +// VerifyCertificate validates structure, ceremony proof, and the exact set of +// server approvals. It returns the canonical statement ID. +func (v *Verifier) VerifyCertificate(certificate Certificate) (string, error) { + if err := VerifyCeremony(certificate.Request); err != nil { + return "", err + } + statement := certificate.Request.Statement + statementID, err := StatementID(statement) + if err != nil { + return "", err + } + if len(certificate.Approvals) != len(statement.Participants) { + return "", ErrCertificateIncomplete + } + + approvals := make(map[string]Approval, len(certificate.Approvals)) + for _, approval := range certificate.Approvals { + if approval.Origin != approval.Account.Origin { + return "", fmt.Errorf("%w: approval origin does not match account", ErrInvalidArtifact) + } + key := approval.Role + "\x00" + approval.Account.key() + if _, exists := approvals[key]; exists { + return "", fmt.Errorf("%w: duplicate approval", ErrInvalidArtifact) + } + publicKey, ok := v.trust.key(approval.Origin, approval.KeyID) + if !ok { + return "", fmt.Errorf("%w: untrusted key %q for %s", ErrInvalidSignature, approval.KeyID, approval.Origin) + } + if err := verifyApproval(statement, approval, ed25519.PublicKey(publicKey)); err != nil { + return "", err + } + approvals[key] = approval + } + for _, participant := range statement.Participants { + key := participant.Role + "\x00" + participant.Account.key() + if _, ok := approvals[key]; !ok { + return "", ErrCertificateIncomplete + } + } + return statementID, nil +} + +// Reconstruct verifies an unordered bundle and returns its single identity +// group. Membership dependencies are resolved by credential ID, not input +// order or server-provided timestamps. +func (v *Verifier) Reconstruct(certificates []Certificate, now time.Time) (*Group, error) { + if len(certificates) == 0 { + return nil, fmt.Errorf("%w: certificate bundle is empty", ErrInvalidArtifact) + } + records := make([]verifiedCertificate, 0, len(certificates)) + seenIDs := map[string]struct{}{} + groupID := "" + for _, certificate := range certificates { + certificateID, err := v.VerifyCertificate(certificate) + if err != nil { + return nil, err + } + if _, exists := seenIDs[certificateID]; exists { + continue + } + seenIDs[certificateID] = struct{}{} + statement := certificate.Request.Statement + if groupID == "" { + groupID = statement.GroupID + } else if statement.GroupID != groupID { + return nil, fmt.Errorf("%w: bundle contains multiple groups", ErrInvalidArtifact) + } + records = append(records, verifiedCertificate{id: certificateID, certificate: certificate}) + } + + group := &Group{ + ID: groupID, + Members: map[string]Account{}, + Credentials: map[string]Credential{}, + } + + genesisCount := 0 + pendingMemberships := make([]verifiedCertificate, 0) + revocations := make([]verifiedCertificate, 0) + for _, record := range records { + statement := record.certificate.Request.Statement + switch statement.Kind { + case KindGenesis: + genesisCount++ + group.GenesisID = record.id + for _, participant := range statement.Participants { + credential := Credential{ + ID: CredentialID(record.id, participant.Account), + GroupID: groupID, + Account: participant.Account, + CertificateID: record.id, + IssuedAt: statement.IssuedAt, + ExpiresAt: statement.ExpiresAt, + } + group.Credentials[credential.ID] = credential + } + case KindMembership: + pendingMemberships = append(pendingMemberships, record) + case KindRevocation: + revocations = append(revocations, record) + } + } + if genesisCount != 1 { + return nil, fmt.Errorf("%w: bundle requires exactly one genesis certificate", ErrInvalidArtifact) + } + + for len(pendingMemberships) > 0 { + progress := false + remaining := pendingMemberships[:0] + for _, record := range pendingMemberships { + statement := record.certificate.Request.Statement + if !sponsorsResolveAt(group.Credentials, statement.Sponsors, statement.IssuedAt) { + remaining = append(remaining, record) + continue + } + credential := Credential{ + ID: CredentialID(record.id, statement.Subject), + GroupID: groupID, + Account: statement.Subject, + CertificateID: record.id, + IssuedAt: statement.IssuedAt, + ExpiresAt: statement.ExpiresAt, + } + group.Credentials[credential.ID] = credential + progress = true + } + if !progress { + return nil, fmt.Errorf("%w: membership sponsor chain is unresolved", ErrInsufficientSponsors) + } + pendingMemberships = remaining + } + + slices.SortFunc(revocations, func(a, b verifiedCertificate) int { + return cmp.Compare(a.certificate.Request.Statement.IssuedAt, b.certificate.Request.Statement.IssuedAt) + }) + for _, record := range revocations { + statement := record.certificate.Request.Statement + credential, ok := group.Credentials[statement.RevokedCredentialID] + if !ok || credential.Account != statement.Subject || !credential.activeAt(statement.IssuedAt) { + return nil, fmt.Errorf("%w: revocation does not name an active subject credential", ErrInvalidArtifact) + } + credential.RevokedAt = statement.IssuedAt + group.Credentials[credential.ID] = credential + } + + // A sponsor revoked before a membership was issued could not authorize that + // membership, even when the dependency graph itself resolves. + for _, record := range records { + statement := record.certificate.Request.Statement + if statement.Kind == KindMembership && !sponsorsResolveAt(group.Credentials, statement.Sponsors, statement.IssuedAt) { + return nil, fmt.Errorf("%w: membership used an inactive sponsor", ErrInsufficientSponsors) + } + } + + activeCredentials := map[string]string{} + for _, credential := range group.Credentials { + if credential.activeAt(now.Unix()) { + accountKey := credential.Account.key() + if previousCredentialID, exists := activeCredentials[accountKey]; exists { + return nil, fmt.Errorf("%w: account has multiple active credentials (%s and %s)", ErrInvalidArtifact, previousCredentialID, credential.ID) + } + activeCredentials[accountKey] = credential.ID + group.Members[accountKey] = credential.Account + } + } + return group, nil +} + +func sponsorsResolveAt(credentials map[string]Credential, sponsors []SponsorRef, at int64) bool { + if len(sponsors) != 2 { + return false + } + origins := map[string]struct{}{} + for _, sponsor := range sponsors { + credential, ok := credentials[sponsor.CredentialID] + if !ok || credential.Account != sponsor.Account || !credential.activeAt(at) { + return false + } + origins[credential.Account.Origin] = struct{}{} + } + return len(origins) == 2 +} + +// MemberAccounts returns active accounts in stable origin/user-ID order. +func (g *Group) MemberAccounts() []Account { + accounts := make([]Account, 0, len(g.Members)) + for _, account := range g.Members { + accounts = append(accounts, account) + } + slices.SortFunc(accounts, func(a, b Account) int { return strings.Compare(a.key(), b.key()) }) + return accounts +} From 69c5f63f2c1cb8efa8f5d2da8b13b82886eea121 Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Tue, 14 Jul 2026 00:49:57 +0200 Subject: [PATCH 2/6] fix(identity): harden brokerage protocol invariants --- cli/internal/identitybroker/README.md | 41 ++-- cli/internal/identitybroker/broker.go | 60 +++-- cli/internal/identitybroker/builders.go | 15 +- .../identitybroker/integration_test.go | 37 +-- cli/internal/identitybroker/protocol.go | 18 +- cli/internal/identitybroker/protocol_test.go | 217 ++++++++++++++---- cli/internal/identitybroker/trust.go | 12 +- cli/internal/identitybroker/validate.go | 9 +- cli/internal/identitybroker/verifier.go | 67 ++++-- 9 files changed, 354 insertions(+), 122 deletions(-) diff --git a/cli/internal/identitybroker/README.md b/cli/internal/identitybroker/README.md index 8d3003520b..63551b8e36 100644 --- a/cli/internal/identitybroker/README.md +++ b/cli/internal/identitybroker/README.md @@ -17,10 +17,11 @@ by a clean client from public certificates: - Every later member is approved by its target server and two current members on distinct servers. - Each participating server signs the exact group, account, role, challenge, - issuance, and expiry fields. -- A disposable client ceremony key binds intercepted artifacts to one ceremony. - The key is discarded after completion and is not part of durable client - state. + issuance, and expiry fields. Credential lifetime is capped at thirty days in + the PoC. +- Before returning its nonce, each server binds its challenge to a disposable + client ceremony key. The key is discarded after completion and is not part + of durable client state. - A verifier reconstructs membership from credential references rather than trusting certificate order or mutable profile fields. - Twenty accounts require nineteen unique certificates: one genesis plus @@ -36,8 +37,9 @@ any ceremony private key. ### Challenge A server issues a short-lived challenge only after authenticating its local -account. The challenge binds an opaque account ID, certificate kind, approval -role, nonce, and expiry. +account. The client first creates its disposable ceremony key. The challenge +then binds that public key together with an opaque account ID, certificate +kind, approval role, nonce, issuance time, and expiry. ### Statement @@ -64,9 +66,11 @@ approvals. ### Membership credential -The genesis certificate grants one credential to each founder. A later -membership certificate grants one credential to its target and identifies the -two existing credentials that sponsored it. Sponsor credentials must: +The genesis certificate's canonical statement ID is the identity group's ID; +the client cannot choose or reuse it. The genesis certificate grants one +credential to each founder. A later membership certificate grants one +credential to its target and identifies the two existing credentials that +sponsored it. Sponsor credentials must: - Belong to the named sponsor accounts. - Come from distinct server origins. @@ -82,7 +86,10 @@ A member can issue a signed revocation for its own credential. Revocation is durable and does not expire. The verifier excludes the credential after the revocation time and rejects memberships sponsored after a sponsor was revoked. -The PoC does not yet define quorum removal, group recovery, or group epochs. +When finalizing a certificate, a broker includes its already-known artifacts +for that group. A caller therefore cannot make that broker ignore a locally +known revocation by omitting it from the supporting bundle. The PoC does not +yet define quorum removal, group recovery, or group epochs. ## Security boundary @@ -95,8 +102,10 @@ It protects against: - Modification of a server origin, account ID, role, group ID, nonce, issuance, or expiry after signing. +- Substituting another ceremony key after a challenge has been issued. - Reusing one challenge for a different statement. - Completing an intercepted ceremony without its disposable client key. +- Aliasing an existing identity group with an unrelated parallel genesis. - Extending a group through one existing malicious server alone, because two current member credentials on distinct origins must sponsor a new member. - Treating certificate input order as authority. @@ -109,6 +118,8 @@ It does not protect against: - A server lying about how it authenticated its own local account. - Correlation after a certificate has been disclosed. - Stale status when servers are unavailable. +- A remote revocation that is withheld from every honest participant and the + verifying client; the PoC only makes locally known state non-omittable. - Server signing-key loss, rotation, or origin migration. The certificate proves account-control continuity as attested by Chatto @@ -128,7 +139,7 @@ implementation must follow Chatto's existing storage boundaries. | Revocation | Durable fact on the local user aggregate in `EVT` | | Current identity-group view | Replay-safe projection derived from `EVT` | | Server signing private key | Dedicated protected key lifecycle, deliberately undecided | -| Server signing public keys | Origin-bound discovery metadata with historical rotation support | +| Server signing public keys | Discovery metadata checked against the authenticated fetch origin, with historical rotation support | Production writes would require JetStream optimistic concurrency over the local user identity-link event family, projection catch-up before returning, and @@ -144,16 +155,16 @@ The PoC deliberately leaves these decisions open: - How server signing keys are generated, protected, backed up, rotated, and retained for historical verification. -- Whether membership certificates expire, renew automatically, or use explicit - group epochs. +- How thirty-day membership certificates renew, and whether production should + instead use another lifetime or explicit group epochs. - How a user recovers when fewer than two existing member accounts remain accessible. - Whether a certificate is private, visible to DM peers, server-member-visible, or public. - How clients obtain the supporting certificate chain without publishing the complete identity group. -- Whether approval should use a disposable signature key, PKCE verifier, or a - standardized sender-constrained authorization mechanism. +- Whether the PoC's challenge-bound disposable signature key should become the + production mechanism or be replaced by a standardized sender constraint. - Which ConnectRPC package and stability tier should expose future operations. - How mixed-version servers negotiate protocol and canonical-encoding versions. diff --git a/cli/internal/identitybroker/broker.go b/cli/internal/identitybroker/broker.go index cc87e43b1f..817b80c391 100644 --- a/cli/internal/identitybroker/broker.go +++ b/cli/internal/identitybroker/broker.go @@ -10,7 +10,10 @@ import ( "time" ) -const DefaultChallengeTTL = 5 * time.Minute +const ( + DefaultChallengeTTL = 5 * time.Minute + MaxCredentialTTL = 30 * 24 * time.Hour +) type issuedApproval struct { statementID string @@ -72,7 +75,7 @@ func (b *Broker) Discovery() DiscoveryKey { // IssueChallenge creates one short-lived authorization checkpoint for a local // authenticated account. -func (b *Broker) IssueChallenge(account Account, kind, role string, now time.Time) (Challenge, error) { +func (b *Broker) IssueChallenge(account Account, kind, role string, ceremonyPublicKey ed25519.PublicKey, now time.Time) (Challenge, error) { if account.Origin != b.origin { return Challenge{}, fmt.Errorf("%w: account belongs to %s, not %s", ErrChallengeMismatch, account.Origin, b.origin) } @@ -82,6 +85,9 @@ func (b *Broker) IssueChallenge(account Account, kind, role string, now time.Tim if !validRoleForKind(kind, role) { return Challenge{}, fmt.Errorf("%w: role %q is not valid for %q", ErrChallengeMismatch, role, kind) } + if len(ceremonyPublicKey) != ed25519.PublicKeySize { + return Challenge{}, fmt.Errorf("%w: ceremony public key has length %d", ErrChallengeMismatch, len(ceremonyPublicKey)) + } id, err := NewOpaqueID(24) if err != nil { return Challenge{}, err @@ -91,17 +97,19 @@ func (b *Broker) IssueChallenge(account Account, kind, role string, now time.Tim return Challenge{}, err } challenge := Challenge{ - ID: id, - Nonce: nonce, - Kind: kind, - Role: role, - Account: account, - ExpiresAt: now.Add(DefaultChallengeTTL).Unix(), + ID: id, + Nonce: nonce, + Kind: kind, + Role: role, + Account: account, + CeremonyPublicKey: append([]byte(nil), ceremonyPublicKey...), + IssuedAt: now.Unix(), + ExpiresAt: now.Add(DefaultChallengeTTL).Unix(), } b.mu.Lock() - b.challenges[id] = challenge + b.challenges[id] = cloneChallenge(challenge) b.mu.Unlock() - return challenge, nil + return cloneChallenge(challenge), nil } // Approve validates and consumes the authenticated account's challenge, or @@ -141,7 +149,10 @@ func (b *Broker) Approve(authenticated Account, request CeremonyRequest, now tim if challenge.Kind != request.Statement.Kind || challenge.Role != participant.Role || challenge.Account != authenticated || challenge.Nonce != participant.Nonce { return Approval{}, ErrChallengeMismatch } - if request.Statement.IssuedAt > now.Add(time.Minute).Unix() || request.Statement.IssuedAt < now.Add(-DefaultChallengeTTL).Unix() { + if !ed25519.PublicKey(challenge.CeremonyPublicKey).Equal(ed25519.PublicKey(request.Statement.CeremonyPublicKey)) { + return Approval{}, fmt.Errorf("%w: ceremony key does not match challenge", ErrChallengeMismatch) + } + if request.Statement.IssuedAt < challenge.IssuedAt || request.Statement.IssuedAt > now.Add(time.Minute).Unix() { return Approval{}, fmt.Errorf("%w: statement issuance is outside the ceremony window", ErrChallengeMismatch) } @@ -167,14 +178,22 @@ func (b *Broker) Finalize(certificate Certificate, verifier *Verifier, supportin if err != nil { return err } - if _, err := verifier.Reconstruct(append(append([]Certificate(nil), supporting...), certificate), now); err != nil { - return err - } if !statementContainsOrigin(certificate.Request.Statement, b.origin) { return fmt.Errorf("%w: certificate does not involve this broker", ErrInvalidArtifact) } b.mu.Lock() defer b.mu.Unlock() + known := make([]Certificate, 0, len(b.certificates)) + for _, stored := range b.certificates { + if certificateGroupID(stored) == certificateGroupID(certificate) { + known = append(known, cloneCertificate(stored)) + } + } + bundle := append(known, supporting...) + bundle = append(bundle, certificate) + if _, err := verifier.Reconstruct(bundle, now); err != nil { + return err + } if existing, ok := b.certificates[statementID]; ok { existingID, existingErr := StatementID(existing.Request.Statement) if existingErr != nil || existingID != statementID { @@ -233,6 +252,19 @@ func cloneApproval(approval Approval) Approval { return approval } +func cloneChallenge(challenge Challenge) Challenge { + challenge.CeremonyPublicKey = append([]byte(nil), challenge.CeremonyPublicKey...) + return challenge +} + +func certificateGroupID(certificate Certificate) string { + if certificate.Request.Statement.Kind != KindGenesis { + return certificate.Request.Statement.GroupID + } + id, _ := StatementID(certificate.Request.Statement) + return id +} + func cloneCertificate(certificate Certificate) Certificate { certificate.Request.Statement.CeremonyPublicKey = append([]byte(nil), certificate.Request.Statement.CeremonyPublicKey...) certificate.Request.Statement.Sponsors = append([]SponsorRef(nil), certificate.Request.Statement.Sponsors...) diff --git a/cli/internal/identitybroker/builders.go b/cli/internal/identitybroker/builders.go index f2c0ee1398..7bc694050e 100644 --- a/cli/internal/identitybroker/builders.go +++ b/cli/internal/identitybroker/builders.go @@ -8,7 +8,7 @@ import ( // NewGenesisStatement constructs the first certificate statement for two // founders on distinct server origins. -func NewGenesisStatement(groupID string, founders []Challenge, ceremonyPublicKey ed25519.PublicKey, issuedAt time.Time, validFor time.Duration) (Statement, error) { +func NewGenesisStatement(founders []Challenge, ceremonyPublicKey ed25519.PublicKey, issuedAt time.Time, validFor time.Duration) (Statement, error) { if len(founders) != 2 { return Statement{}, fmt.Errorf("%w: genesis requires two founder challenges", ErrInvalidArtifact) } @@ -17,12 +17,14 @@ func NewGenesisStatement(groupID string, founders []Challenge, ceremonyPublicKey if challenge.Kind != KindGenesis || challenge.Role != RoleFounder { return Statement{}, ErrChallengeMismatch } + if !ed25519.PublicKey(challenge.CeremonyPublicKey).Equal(ceremonyPublicKey) { + return Statement{}, fmt.Errorf("%w: founder challenge uses another ceremony key", ErrChallengeMismatch) + } participants = append(participants, participantFromChallenge(challenge)) } statement := Statement{ Version: ProtocolVersion, Kind: KindGenesis, - GroupID: groupID, Participants: participants, CeremonyPublicKey: append([]byte(nil), ceremonyPublicKey...), IssuedAt: issuedAt.Unix(), @@ -37,11 +39,17 @@ func NewMembershipStatement(groupID string, target Challenge, sponsors []Challen if target.Kind != KindMembership || target.Role != RoleTarget || len(sponsors) != 2 || len(sponsorRefs) != 2 { return Statement{}, ErrInsufficientSponsors } + if !ed25519.PublicKey(target.CeremonyPublicKey).Equal(ceremonyPublicKey) { + return Statement{}, fmt.Errorf("%w: target challenge uses another ceremony key", ErrChallengeMismatch) + } participants := []Participant{participantFromChallenge(target)} for _, challenge := range sponsors { if challenge.Kind != KindMembership || challenge.Role != RoleSponsor { return Statement{}, ErrChallengeMismatch } + if !ed25519.PublicKey(challenge.CeremonyPublicKey).Equal(ceremonyPublicKey) { + return Statement{}, fmt.Errorf("%w: sponsor challenge uses another ceremony key", ErrChallengeMismatch) + } participants = append(participants, participantFromChallenge(challenge)) } statement := Statement{ @@ -63,6 +71,9 @@ func NewRevocationStatement(groupID, credentialID string, member Challenge, cere if member.Kind != KindRevocation || member.Role != RoleMember { return Statement{}, ErrChallengeMismatch } + if !ed25519.PublicKey(member.CeremonyPublicKey).Equal(ceremonyPublicKey) { + return Statement{}, fmt.Errorf("%w: member challenge uses another ceremony key", ErrChallengeMismatch) + } statement := Statement{ Version: ProtocolVersion, Kind: KindRevocation, diff --git a/cli/internal/identitybroker/integration_test.go b/cli/internal/identitybroker/integration_test.go index 8fb1640552..ceca995749 100644 --- a/cli/internal/identitybroker/integration_test.go +++ b/cli/internal/identitybroker/integration_test.go @@ -23,8 +23,9 @@ type httpBrokerServer struct { } type challengeHTTPRequest struct { - Kind string `json:"kind"` - Role string `json:"role"` + Kind string `json:"kind"` + Role string `json:"role"` + CeremonyPublicKey []byte `json:"ceremony_public_key"` } type finalizeHTTPRequest struct { @@ -75,7 +76,7 @@ func (h *httpBrokerServer) handler() http.Handler { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - challenge, err := h.broker.IssueChallenge(h.account, request.Kind, request.Role, testNow) + challenge, err := h.broker.IssueChallenge(h.account, request.Kind, request.Role, request.CeremonyPublicKey, testNow) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return @@ -179,16 +180,20 @@ func discoverHTTPBrokers(t *testing.T, brokers []*httpBrokerServer) *TrustStore trust := NewTrustStore() for _, broker := range brokers { discovery := httpJSON[DiscoveryKey](t, http.MethodGet, broker.server.URL+"/.well-known/chatto-identity-broker", "", nil) - if err := trust.Add(discovery); err != nil { + if err := trust.Add(broker.server.URL, discovery); err != nil { t.Fatalf("trust.Add(%s): %v", broker.server.URL, err) } } return trust } -func httpChallenge(t *testing.T, broker *httpBrokerServer, kind, role string) Challenge { +func httpChallenge(t *testing.T, broker *httpBrokerServer, kind, role string, ceremonyPublicKey []byte) Challenge { t.Helper() - return httpJSON[Challenge](t, http.MethodPost, broker.server.URL+"/challenge", broker.token, challengeHTTPRequest{Kind: kind, Role: role}) + return httpJSON[Challenge](t, http.MethodPost, broker.server.URL+"/challenge", broker.token, challengeHTTPRequest{ + Kind: kind, + Role: role, + CeremonyPublicKey: ceremonyPublicKey, + }) } func httpApprovals(t *testing.T, request CeremonyRequest, brokers ...*httpBrokerServer) []Approval { @@ -211,17 +216,13 @@ func httpFinalize(t *testing.T, broker *httpBrokerServer, certificate Certificat func httpGenesis(t *testing.T, first, second *httpBrokerServer, now time.Time) (Certificate, string) { t.Helper() - groupID, err := NewOpaqueID(32) - if err != nil { - t.Fatal(err) - } publicKey, privateKey, err := NewCeremonyKey() if err != nil { t.Fatal(err) } - statement, err := NewGenesisStatement(groupID, []Challenge{ - httpChallenge(t, first, KindGenesis, RoleFounder), - httpChallenge(t, second, KindGenesis, RoleFounder), + statement, err := NewGenesisStatement([]Challenge{ + httpChallenge(t, first, KindGenesis, RoleFounder, publicKey), + httpChallenge(t, second, KindGenesis, RoleFounder, publicKey), }, publicKey, now, 72*time.Hour) if err != nil { t.Fatal(err) @@ -230,6 +231,10 @@ func httpGenesis(t *testing.T, first, second *httpBrokerServer, now time.Time) ( if err != nil { t.Fatal(err) } + groupID, err := StatementID(statement) + if err != nil { + t.Fatal(err) + } return Certificate{Request: request, Approvals: httpApprovals(t, request, first, second)}, groupID } @@ -241,10 +246,10 @@ func httpMembership(t *testing.T, groupID string, target, sponsorA, sponsorB *ht } statement, err := NewMembershipStatement( groupID, - httpChallenge(t, target, KindMembership, RoleTarget), + httpChallenge(t, target, KindMembership, RoleTarget, publicKey), []Challenge{ - httpChallenge(t, sponsorA, KindMembership, RoleSponsor), - httpChallenge(t, sponsorB, KindMembership, RoleSponsor), + httpChallenge(t, sponsorA, KindMembership, RoleSponsor, publicKey), + httpChallenge(t, sponsorB, KindMembership, RoleSponsor, publicKey), }, refs, publicKey, diff --git a/cli/internal/identitybroker/protocol.go b/cli/internal/identitybroker/protocol.go index 7a07080a79..df008207c1 100644 --- a/cli/internal/identitybroker/protocol.go +++ b/cli/internal/identitybroker/protocol.go @@ -65,12 +65,14 @@ func (a Account) key() string { // Challenge is a short-lived, single-server authorization checkpoint. A // production implementation would persist it in RUNTIME_STATE with a TTL. type Challenge struct { - ID string `json:"id"` - Nonce string `json:"nonce"` - Kind string `json:"kind"` - Role string `json:"role"` - Account Account `json:"account"` - ExpiresAt int64 `json:"expires_at"` + ID string `json:"id"` + Nonce string `json:"nonce"` + Kind string `json:"kind"` + Role string `json:"role"` + Account Account `json:"account"` + CeremonyPublicKey []byte `json:"ceremony_public_key"` + IssuedAt int64 `json:"issued_at"` + ExpiresAt int64 `json:"expires_at"` } // Participant binds one expected server approval to the exact challenge that @@ -106,8 +108,8 @@ type Statement struct { } // CeremonyRequest is presented independently to every participating server. -// The disposable ceremony signature prevents intercepted requests from being -// completed by a different client. +// Every challenge commits to the disposable key before disclosing its nonce, +// so an intercepted request cannot be completed with a substituted client key. type CeremonyRequest struct { Statement Statement `json:"statement"` CeremonySignature []byte `json:"ceremony_signature"` diff --git a/cli/internal/identitybroker/protocol_test.go b/cli/internal/identitybroker/protocol_test.go index 6d92be3da3..a5bed94c16 100644 --- a/cli/internal/identitybroker/protocol_test.go +++ b/cli/internal/identitybroker/protocol_test.go @@ -28,16 +28,16 @@ func trustMembers(t *testing.T, members ...testMember) *TrustStore { t.Helper() trust := NewTrustStore() for _, member := range members { - if err := trust.Add(member.broker.Discovery()); err != nil { + if err := trust.Add(member.account.Origin, member.broker.Discovery()); err != nil { t.Fatalf("trust.Add(%s): %v", member.account.Origin, err) } } return trust } -func issueChallenge(t *testing.T, member testMember, kind, role string, now time.Time) Challenge { +func issueChallenge(t *testing.T, member testMember, kind, role string, ceremonyPublicKey ed25519.PublicKey, now time.Time) Challenge { t.Helper() - challenge, err := member.broker.IssueChallenge(member.account, kind, role, now) + challenge, err := member.broker.IssueChallenge(member.account, kind, role, ceremonyPublicKey, now) if err != nil { t.Fatalf("IssueChallenge(%s, %s): %v", kind, role, err) } @@ -63,22 +63,23 @@ func signWithMembers(t *testing.T, statement Statement, privateKey ed25519.Priva func makeGenesis(t *testing.T, first, second testMember, now time.Time) (Certificate, string) { t.Helper() - groupID, err := NewOpaqueID(32) - if err != nil { - t.Fatalf("NewOpaqueID: %v", err) - } publicKey, privateKey, err := NewCeremonyKey() if err != nil { t.Fatalf("NewCeremonyKey: %v", err) } - statement, err := NewGenesisStatement(groupID, []Challenge{ - issueChallenge(t, first, KindGenesis, RoleFounder, now), - issueChallenge(t, second, KindGenesis, RoleFounder, now), + statement, err := NewGenesisStatement([]Challenge{ + issueChallenge(t, first, KindGenesis, RoleFounder, publicKey, now), + issueChallenge(t, second, KindGenesis, RoleFounder, publicKey, now), }, publicKey, now, 24*time.Hour) if err != nil { t.Fatalf("NewGenesisStatement: %v", err) } - return signWithMembers(t, statement, privateKey, now, first, second), groupID + certificate := signWithMembers(t, statement, privateKey, now, first, second) + groupID, err := StatementID(statement) + if err != nil { + t.Fatalf("StatementID(genesis): %v", err) + } + return certificate, groupID } func makeMembership(t *testing.T, groupID string, target, sponsorA, sponsorB testMember, refs []SponsorRef, now time.Time) Certificate { @@ -89,10 +90,10 @@ func makeMembership(t *testing.T, groupID string, target, sponsorA, sponsorB tes } statement, err := NewMembershipStatement( groupID, - issueChallenge(t, target, KindMembership, RoleTarget, now), + issueChallenge(t, target, KindMembership, RoleTarget, publicKey, now), []Challenge{ - issueChallenge(t, sponsorA, KindMembership, RoleSponsor, now), - issueChallenge(t, sponsorB, KindMembership, RoleSponsor, now), + issueChallenge(t, sponsorA, KindMembership, RoleSponsor, publicKey, now), + issueChallenge(t, sponsorB, KindMembership, RoleSponsor, publicKey, now), }, refs, publicKey, @@ -161,8 +162,8 @@ func TestCertificateRejectsTampering(t *testing.T) { t.Run(name, func(t *testing.T) { tampered := cloneCertificate(genesis) mutate(&tampered) - if _, err := verifier.VerifyCertificate(tampered); !errors.Is(err, ErrInvalidSignature) { - t.Fatalf("VerifyCertificate error = %v, want invalid signature", err) + if _, err := verifier.VerifyCertificate(tampered); !errors.Is(err, ErrInvalidSignature) && !errors.Is(err, ErrInvalidArtifact) { + t.Fatalf("VerifyCertificate error = %v, want tampering rejection", err) } }) } @@ -182,10 +183,6 @@ func TestCertificateRequiresEveryOriginKeyToBeTrusted(t *testing.T) { func TestCeremonyCannotBeCompletedWithAnotherPrivateKey(t *testing.T) { first := newTestMember(t, "https://one.example", "user-one") second := newTestMember(t, "https://two.example", "user-two") - groupID, err := NewOpaqueID(32) - if err != nil { - t.Fatal(err) - } publicKey, _, err := NewCeremonyKey() if err != nil { t.Fatal(err) @@ -194,9 +191,9 @@ func TestCeremonyCannotBeCompletedWithAnotherPrivateKey(t *testing.T) { if err != nil { t.Fatal(err) } - statement, err := NewGenesisStatement(groupID, []Challenge{ - issueChallenge(t, first, KindGenesis, RoleFounder, testNow), - issueChallenge(t, second, KindGenesis, RoleFounder, testNow), + statement, err := NewGenesisStatement([]Challenge{ + issueChallenge(t, first, KindGenesis, RoleFounder, publicKey, testNow), + issueChallenge(t, second, KindGenesis, RoleFounder, publicKey, testNow), }, publicKey, testNow, time.Hour) if err != nil { t.Fatal(err) @@ -209,17 +206,13 @@ func TestCeremonyCannotBeCompletedWithAnotherPrivateKey(t *testing.T) { func TestApprovalIsIdempotentButChallengeCannotChangeStatements(t *testing.T) { first := newTestMember(t, "https://one.example", "user-one") second := newTestMember(t, "https://two.example", "user-two") - groupID, err := NewOpaqueID(32) - if err != nil { - t.Fatal(err) - } publicKey, privateKey, err := NewCeremonyKey() if err != nil { t.Fatal(err) } - statement, err := NewGenesisStatement(groupID, []Challenge{ - issueChallenge(t, first, KindGenesis, RoleFounder, testNow), - issueChallenge(t, second, KindGenesis, RoleFounder, testNow), + statement, err := NewGenesisStatement([]Challenge{ + issueChallenge(t, first, KindGenesis, RoleFounder, publicKey, testNow), + issueChallenge(t, second, KindGenesis, RoleFounder, publicKey, testNow), }, publicKey, testNow, time.Hour) if err != nil { t.Fatal(err) @@ -253,17 +246,13 @@ func TestApprovalIsIdempotentButChallengeCannotChangeStatements(t *testing.T) { func TestExpiredChallengeIsRejected(t *testing.T) { first := newTestMember(t, "https://one.example", "user-one") second := newTestMember(t, "https://two.example", "user-two") - groupID, err := NewOpaqueID(32) - if err != nil { - t.Fatal(err) - } publicKey, privateKey, err := NewCeremonyKey() if err != nil { t.Fatal(err) } - statement, err := NewGenesisStatement(groupID, []Challenge{ - issueChallenge(t, first, KindGenesis, RoleFounder, testNow), - issueChallenge(t, second, KindGenesis, RoleFounder, testNow), + statement, err := NewGenesisStatement([]Challenge{ + issueChallenge(t, first, KindGenesis, RoleFounder, publicKey, testNow), + issueChallenge(t, second, KindGenesis, RoleFounder, publicKey, testNow), }, publicKey, testNow, time.Hour) if err != nil { t.Fatal(err) @@ -281,19 +270,19 @@ func TestOneExistingServerCannotSponsorMembershipTwice(t *testing.T) { sponsor := newTestMember(t, "https://sponsor.example", "sponsor-one") target := newTestMember(t, "https://target.example", "target") secondSponsorAccount := Account{Origin: sponsor.account.Origin, UserID: "sponsor-two"} - secondChallenge, err := sponsor.broker.IssueChallenge(secondSponsorAccount, KindMembership, RoleSponsor, testNow) + publicKey, _, err := NewCeremonyKey() if err != nil { t.Fatal(err) } - publicKey, _, err := NewCeremonyKey() + secondChallenge, err := sponsor.broker.IssueChallenge(secondSponsorAccount, KindMembership, RoleSponsor, publicKey, testNow) if err != nil { t.Fatal(err) } _, err = NewMembershipStatement( "group", - issueChallenge(t, target, KindMembership, RoleTarget, testNow), + issueChallenge(t, target, KindMembership, RoleTarget, publicKey, testNow), []Challenge{ - issueChallenge(t, sponsor, KindMembership, RoleSponsor, testNow), + issueChallenge(t, sponsor, KindMembership, RoleSponsor, publicKey, testNow), secondChallenge, }, []SponsorRef{ @@ -329,7 +318,7 @@ func TestRevocationRemovesMember(t *testing.T) { statement, err := NewRevocationStatement( groupID, credentialID, - issueChallenge(t, third, KindRevocation, RoleMember, revokedAt), + issueChallenge(t, third, KindRevocation, RoleMember, publicKey, revokedAt), publicKey, revokedAt, ) @@ -366,7 +355,7 @@ func TestMembershipSponsoredAfterRevocationIsRejected(t *testing.T) { revocationStatement, err := NewRevocationStatement( groupID, firstCredentialID, - issueChallenge(t, first, KindRevocation, RoleMember, revokedAt), + issueChallenge(t, first, KindRevocation, RoleMember, publicKey, revokedAt), publicKey, revokedAt, ) @@ -418,3 +407,145 @@ func TestFinalizeIsIdempotentAndResumableAcrossBrokers(t *testing.T) { t.Fatalf("second certificates after resume = %d, want 1", got) } } + +func TestTrustStoreRejectsDiscoveryForAnotherFetchOrigin(t *testing.T) { + honest := newTestMember(t, "https://honest.example", "honest") + trust := NewTrustStore() + if err := trust.Add("https://attacker.example", honest.broker.Discovery()); !errors.Is(err, ErrInvalidArtifact) { + t.Fatalf("TrustStore.Add error = %v, want mismatched-origin rejection", err) + } +} + +func TestChallengeRejectsCeremonyKeySubstitution(t *testing.T) { + first := newTestMember(t, "https://one.example", "user-one") + second := newTestMember(t, "https://two.example", "user-two") + publicKey, _, err := NewCeremonyKey() + if err != nil { + t.Fatal(err) + } + attackerPublicKey, attackerPrivateKey, err := NewCeremonyKey() + if err != nil { + t.Fatal(err) + } + statement, err := NewGenesisStatement([]Challenge{ + issueChallenge(t, first, KindGenesis, RoleFounder, publicKey, testNow), + issueChallenge(t, second, KindGenesis, RoleFounder, publicKey, testNow), + }, publicKey, testNow, time.Hour) + if err != nil { + t.Fatal(err) + } + statement.CeremonyPublicKey = attackerPublicKey + request, err := SignCeremony(statement, attackerPrivateKey) + if err != nil { + t.Fatal(err) + } + if _, err := first.broker.Approve(first.account, request, testNow); !errors.Is(err, ErrChallengeMismatch) { + t.Fatalf("Approve error = %v, want ceremony-key mismatch", err) + } +} + +func TestFreshChallengeRejectsBackdatedStatement(t *testing.T) { + first := newTestMember(t, "https://one.example", "user-one") + second := newTestMember(t, "https://two.example", "user-two") + publicKey, privateKey, err := NewCeremonyKey() + if err != nil { + t.Fatal(err) + } + challengeTime := testNow.Add(2 * time.Minute) + statement, err := NewGenesisStatement([]Challenge{ + issueChallenge(t, first, KindGenesis, RoleFounder, publicKey, challengeTime), + issueChallenge(t, second, KindGenesis, RoleFounder, publicKey, challengeTime), + }, publicKey, testNow, time.Hour) + if err != nil { + t.Fatal(err) + } + request, err := SignCeremony(statement, privateKey) + if err != nil { + t.Fatal(err) + } + if _, err := first.broker.Approve(first.account, request, challengeTime); !errors.Is(err, ErrChallengeMismatch) { + t.Fatalf("Approve error = %v, want backdating rejection", err) + } +} + +func TestGroupIDIsDerivedFromUniqueGenesis(t *testing.T) { + first := newTestMember(t, "https://one.example", "user-one") + second := newTestMember(t, "https://two.example", "user-two") + attackerA := newTestMember(t, "https://attacker-a.example", "attacker-a") + attackerB := newTestMember(t, "https://attacker-b.example", "attacker-b") + verifier := NewVerifier(trustMembers(t, first, second, attackerA, attackerB)) + + genesis, groupID := makeGenesis(t, first, second, testNow) + parallelGenesis, parallelGroupID := makeGenesis(t, attackerA, attackerB, testNow) + if groupID == parallelGroupID { + t.Fatal("unrelated genesis certificates produced the same group id") + } + for _, test := range []struct { + certificate Certificate + wantID string + }{{genesis, groupID}, {parallelGenesis, parallelGroupID}} { + group, err := verifier.Reconstruct([]Certificate{test.certificate}, testNow) + if err != nil { + t.Fatal(err) + } + if group.ID != test.wantID || group.GenesisID != test.wantID { + t.Fatalf("group identity = (%q, %q), want %q", group.ID, group.GenesisID, test.wantID) + } + } +} + +func TestCredentialLifetimeIsBounded(t *testing.T) { + first := newTestMember(t, "https://one.example", "user-one") + second := newTestMember(t, "https://two.example", "user-two") + publicKey, _, err := NewCeremonyKey() + if err != nil { + t.Fatal(err) + } + _, err = NewGenesisStatement([]Challenge{ + issueChallenge(t, first, KindGenesis, RoleFounder, publicKey, testNow), + issueChallenge(t, second, KindGenesis, RoleFounder, publicKey, testNow), + }, publicKey, testNow, MaxCredentialTTL+time.Second) + if !errors.Is(err, ErrInvalidArtifact) { + t.Fatalf("NewGenesisStatement error = %v, want excessive-lifetime rejection", err) + } +} + +func TestFinalizeIncludesKnownRevocationWhenSupportingBundleOmitsIt(t *testing.T) { + first := newTestMember(t, "https://one.example", "user-one") + second := newTestMember(t, "https://two.example", "user-two") + target := newTestMember(t, "https://target.example", "target") + verifier := NewVerifier(trustMembers(t, first, second, target)) + genesis, groupID := makeGenesis(t, first, second, testNow) + if err := first.broker.Finalize(genesis, verifier, nil, testNow); err != nil { + t.Fatal(err) + } + + genesisID, err := StatementID(genesis.Request.Statement) + if err != nil { + t.Fatal(err) + } + publicKey, privateKey, err := NewCeremonyKey() + if err != nil { + t.Fatal(err) + } + revokedAt := testNow.Add(time.Second) + revocationStatement, err := NewRevocationStatement( + groupID, + CredentialID(genesisID, first.account), + issueChallenge(t, first, KindRevocation, RoleMember, publicKey, revokedAt), + publicKey, + revokedAt, + ) + if err != nil { + t.Fatal(err) + } + revocation := signWithMembers(t, revocationStatement, privateKey, revokedAt, first) + if err := first.broker.Finalize(revocation, verifier, []Certificate{genesis}, revokedAt); err != nil { + t.Fatal(err) + } + + membership := makeMembership(t, groupID, target, first, second, founderRefs(t, genesis, first, second), revokedAt.Add(time.Second)) + if err := first.broker.Finalize(membership, verifier, []Certificate{genesis}, revokedAt.Add(time.Second)); !errors.Is(err, ErrInsufficientSponsors) { + t.Fatalf("Finalize error = %v, want known-revocation rejection", err) + } +} diff --git a/cli/internal/identitybroker/trust.go b/cli/internal/identitybroker/trust.go index 43b65cf5c4..a116ced399 100644 --- a/cli/internal/identitybroker/trust.go +++ b/cli/internal/identitybroker/trust.go @@ -19,12 +19,18 @@ func NewTrustStore() *TrustStore { return &TrustStore{keys: map[string]map[string]ed25519.PublicKey{}} } -// Add pins one discovered public key to its exact origin and key ID. -func (s *TrustStore) Add(discovery DiscoveryKey) error { - origin, err := NormalizeOrigin(discovery.Origin) +// Add pins one discovered public key to the origin from which the discovery +// document was fetched. Callers must pass the authenticated request origin; +// the document cannot nominate a different origin for its key. +func (s *TrustStore) Add(expectedOrigin string, discovery DiscoveryKey) error { + origin, err := NormalizeOrigin(expectedOrigin) if err != nil { return err } + discoveredOrigin, err := NormalizeOrigin(discovery.Origin) + if err != nil || discoveredOrigin != origin || discovery.Origin != origin { + return fmt.Errorf("%w: discovery origin does not match fetch origin", ErrInvalidArtifact) + } if discovery.Protocol != ProtocolVersion || discovery.KeyID == "" || len(discovery.PublicKey) != ed25519.PublicKeySize { return fmt.Errorf("%w: invalid discovery key", ErrInvalidArtifact) } diff --git a/cli/internal/identitybroker/validate.go b/cli/internal/identitybroker/validate.go index fb8f800140..6720902e68 100644 --- a/cli/internal/identitybroker/validate.go +++ b/cli/internal/identitybroker/validate.go @@ -5,13 +5,14 @@ import ( "fmt" "slices" "strings" + "time" ) func validateStatement(statement Statement) error { if statement.Version != ProtocolVersion { return fmt.Errorf("%w: unsupported protocol version %q", ErrInvalidArtifact, statement.Version) } - if strings.TrimSpace(statement.GroupID) == "" { + if statement.Kind != KindGenesis && strings.TrimSpace(statement.GroupID) == "" { return fmt.Errorf("%w: group id is empty", ErrInvalidArtifact) } if len(statement.CeremonyPublicKey) != ed25519.PublicKeySize { @@ -23,6 +24,9 @@ func validateStatement(statement Statement) error { if statement.Kind != KindRevocation && statement.ExpiresAt <= statement.IssuedAt { return fmt.Errorf("%w: credential expiry must follow issuance", ErrInvalidArtifact) } + if statement.Kind != KindRevocation && statement.ExpiresAt-statement.IssuedAt > int64(MaxCredentialTTL/time.Second) { + return fmt.Errorf("%w: credential lifetime exceeds %s", ErrInvalidArtifact, MaxCredentialTTL) + } participants := append([]Participant(nil), statement.Participants...) sortParticipants(participants) @@ -51,6 +55,9 @@ func validateStatement(statement Statement) error { } func validateGenesisStatement(statement Statement) error { + if statement.GroupID != "" { + return fmt.Errorf("%w: genesis group id must be derived from its certificate", ErrInvalidArtifact) + } if statement.Subject != (Account{}) || statement.RevokedCredentialID != "" || len(statement.Sponsors) != 0 { return fmt.Errorf("%w: genesis contains membership or revocation fields", ErrInvalidArtifact) } diff --git a/cli/internal/identitybroker/verifier.go b/cli/internal/identitybroker/verifier.go index b50c8dfcc0..64db72bfda 100644 --- a/cli/internal/identitybroker/verifier.go +++ b/cli/internal/identitybroker/verifier.go @@ -98,7 +98,6 @@ func (v *Verifier) Reconstruct(certificates []Certificate, now time.Time) (*Grou } records := make([]verifiedCertificate, 0, len(certificates)) seenIDs := map[string]struct{}{} - groupID := "" for _, certificate := range certificates { certificateID, err := v.VerifyCertificate(certificate) if err != nil { @@ -108,30 +107,42 @@ func (v *Verifier) Reconstruct(certificates []Certificate, now time.Time) (*Grou continue } seenIDs[certificateID] = struct{}{} - statement := certificate.Request.Statement - if groupID == "" { - groupID = statement.GroupID - } else if statement.GroupID != groupID { - return nil, fmt.Errorf("%w: bundle contains multiple groups", ErrInvalidArtifact) - } records = append(records, verifiedCertificate{id: certificateID, certificate: certificate}) } + genesisCount := 0 + groupID := "" + genesisID := "" + for _, record := range records { + if record.certificate.Request.Statement.Kind == KindGenesis { + genesisCount++ + genesisID = record.id + groupID = record.id + } + } + if genesisCount != 1 { + return nil, fmt.Errorf("%w: bundle requires exactly one genesis certificate", ErrInvalidArtifact) + } + for _, record := range records { + statement := record.certificate.Request.Statement + if statement.Kind != KindGenesis && statement.GroupID != groupID { + return nil, fmt.Errorf("%w: certificate does not reference the bundle genesis", ErrInvalidArtifact) + } + } + group := &Group{ ID: groupID, + GenesisID: genesisID, Members: map[string]Account{}, Credentials: map[string]Credential{}, } - genesisCount := 0 pendingMemberships := make([]verifiedCertificate, 0) revocations := make([]verifiedCertificate, 0) for _, record := range records { statement := record.certificate.Request.Statement switch statement.Kind { case KindGenesis: - genesisCount++ - group.GenesisID = record.id for _, participant := range statement.Participants { credential := Credential{ ID: CredentialID(record.id, participant.Account), @@ -149,10 +160,6 @@ func (v *Verifier) Reconstruct(certificates []Certificate, now time.Time) (*Grou revocations = append(revocations, record) } } - if genesisCount != 1 { - return nil, fmt.Errorf("%w: bundle requires exactly one genesis certificate", ErrInvalidArtifact) - } - for len(pendingMemberships) > 0 { progress := false remaining := pendingMemberships[:0] @@ -201,20 +208,40 @@ func (v *Verifier) Reconstruct(certificates []Certificate, now time.Time) (*Grou } } - activeCredentials := map[string]string{} + credentialsByAccount := map[string][]Credential{} for _, credential := range group.Credentials { + accountKey := credential.Account.key() + credentialsByAccount[accountKey] = append(credentialsByAccount[accountKey], credential) if credential.activeAt(now.Unix()) { - accountKey := credential.Account.key() - if previousCredentialID, exists := activeCredentials[accountKey]; exists { - return nil, fmt.Errorf("%w: account has multiple active credentials (%s and %s)", ErrInvalidArtifact, previousCredentialID, credential.ID) - } - activeCredentials[accountKey] = credential.ID group.Members[accountKey] = credential.Account } } + for _, accountCredentials := range credentialsByAccount { + slices.SortFunc(accountCredentials, func(a, b Credential) int { return cmp.Compare(a.IssuedAt, b.IssuedAt) }) + previousID := accountCredentials[0].ID + previousEnd := credentialEnd(accountCredentials[0]) + for i := 1; i < len(accountCredentials); i++ { + credential := accountCredentials[i] + if credential.IssuedAt < previousEnd { + return nil, fmt.Errorf("%w: account credentials overlap (%s and %s)", ErrInvalidArtifact, previousID, credential.ID) + } + end := credentialEnd(credential) + if end > previousEnd { + previousEnd = end + previousID = credential.ID + } + } + } return group, nil } +func credentialEnd(credential Credential) int64 { + if credential.RevokedAt != 0 && credential.RevokedAt < credential.ExpiresAt { + return credential.RevokedAt + } + return credential.ExpiresAt +} + func sponsorsResolveAt(credentials map[string]Credential, sponsors []SponsorRef, at int64) bool { if len(sponsors) != 2 { return false From 8d73136c8863b8a8010a0f35eacd9582d36a6bb7 Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Tue, 14 Jul 2026 00:55:12 +0200 Subject: [PATCH 3/6] fix(identity): reject revoked sponsor approvals --- cli/internal/identitybroker/README.md | 9 +++--- cli/internal/identitybroker/broker.go | 15 ++++++++++ cli/internal/identitybroker/protocol_test.go | 31 +++++++++++++++++--- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/cli/internal/identitybroker/README.md b/cli/internal/identitybroker/README.md index 63551b8e36..32967aecca 100644 --- a/cli/internal/identitybroker/README.md +++ b/cli/internal/identitybroker/README.md @@ -86,10 +86,11 @@ A member can issue a signed revocation for its own credential. Revocation is durable and does not expire. The verifier excludes the credential after the revocation time and rejects memberships sponsored after a sponsor was revoked. -When finalizing a certificate, a broker includes its already-known artifacts -for that group. A caller therefore cannot make that broker ignore a locally -known revocation by omitting it from the supporting bundle. The PoC does not -yet define quorum removal, group recovery, or group epochs. +A broker refuses to sponsor with a locally known revoked credential. When +finalizing a certificate, it also includes its already-known artifacts for that +group. A caller therefore cannot make that broker ignore a local revocation by +skipping finalization there or omitting it from the supporting bundle. The PoC +does not yet define quorum removal, group recovery, or group epochs. ## Security boundary diff --git a/cli/internal/identitybroker/broker.go b/cli/internal/identitybroker/broker.go index 817b80c391..d124e5a034 100644 --- a/cli/internal/identitybroker/broker.go +++ b/cli/internal/identitybroker/broker.go @@ -34,6 +34,7 @@ type Broker struct { challenges map[string]Challenge approvals map[string]issuedApproval certificates map[string]Certificate + revoked map[string]struct{} } // NewBroker creates an isolated in-memory protocol actor and signing identity. @@ -55,6 +56,7 @@ func NewBroker(origin string) (*Broker, error) { challenges: map[string]Challenge{}, approvals: map[string]issuedApproval{}, certificates: map[string]Certificate{}, + revoked: map[string]struct{}{}, }, nil } @@ -155,6 +157,16 @@ func (b *Broker) Approve(authenticated Account, request CeremonyRequest, now tim if request.Statement.IssuedAt < challenge.IssuedAt || request.Statement.IssuedAt > now.Add(time.Minute).Unix() { return Approval{}, fmt.Errorf("%w: statement issuance is outside the ceremony window", ErrChallengeMismatch) } + if participant.Role == RoleSponsor { + for _, sponsor := range request.Statement.Sponsors { + if sponsor.Account == authenticated { + if _, revoked := b.revoked[sponsor.CredentialID]; revoked { + return Approval{}, fmt.Errorf("%w: local sponsor credential is revoked", ErrInsufficientSponsors) + } + break + } + } + } signature, err := signApproval(request.Statement, participant.Role, authenticated, b.privateKey) if err != nil { @@ -201,6 +213,9 @@ func (b *Broker) Finalize(certificate Certificate, verifier *Verifier, supportin } return nil } + if certificate.Request.Statement.Kind == KindRevocation { + b.revoked[certificate.Request.Statement.RevokedCredentialID] = struct{}{} + } b.certificates[statementID] = cloneCertificate(certificate) return nil } diff --git a/cli/internal/identitybroker/protocol_test.go b/cli/internal/identitybroker/protocol_test.go index a5bed94c16..d9f5f35b1d 100644 --- a/cli/internal/identitybroker/protocol_test.go +++ b/cli/internal/identitybroker/protocol_test.go @@ -510,7 +510,7 @@ func TestCredentialLifetimeIsBounded(t *testing.T) { } } -func TestFinalizeIncludesKnownRevocationWhenSupportingBundleOmitsIt(t *testing.T) { +func TestSponsorApprovalRejectsKnownRevocationBeforeSelectiveFinalization(t *testing.T) { first := newTestMember(t, "https://one.example", "user-one") second := newTestMember(t, "https://two.example", "user-two") target := newTestMember(t, "https://target.example", "target") @@ -544,8 +544,31 @@ func TestFinalizeIncludesKnownRevocationWhenSupportingBundleOmitsIt(t *testing.T t.Fatal(err) } - membership := makeMembership(t, groupID, target, first, second, founderRefs(t, genesis, first, second), revokedAt.Add(time.Second)) - if err := first.broker.Finalize(membership, verifier, []Certificate{genesis}, revokedAt.Add(time.Second)); !errors.Is(err, ErrInsufficientSponsors) { - t.Fatalf("Finalize error = %v, want known-revocation rejection", err) + publicKey, privateKey, err = NewCeremonyKey() + if err != nil { + t.Fatal(err) + } + membershipAt := revokedAt.Add(time.Second) + membershipStatement, err := NewMembershipStatement( + groupID, + issueChallenge(t, target, KindMembership, RoleTarget, publicKey, membershipAt), + []Challenge{ + issueChallenge(t, first, KindMembership, RoleSponsor, publicKey, membershipAt), + issueChallenge(t, second, KindMembership, RoleSponsor, publicKey, membershipAt), + }, + founderRefs(t, genesis, first, second), + publicKey, + membershipAt, + time.Hour, + ) + if err != nil { + t.Fatal(err) + } + request, err := SignCeremony(membershipStatement, privateKey) + if err != nil { + t.Fatal(err) + } + if _, err := first.broker.Approve(first.account, request, membershipAt); !errors.Is(err, ErrInsufficientSponsors) { + t.Fatalf("Approve error = %v, want known-revocation rejection", err) } } From 65808e2431442cc922231518787b76b1b86e9133 Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Tue, 14 Jul 2026 00:57:59 +0200 Subject: [PATCH 4/6] fix(identity): commit revocation with approval --- cli/internal/identitybroker/README.md | 5 +++-- cli/internal/identitybroker/broker.go | 11 ++++++++++- cli/internal/identitybroker/protocol_test.go | 7 ++----- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/cli/internal/identitybroker/README.md b/cli/internal/identitybroker/README.md index 32967aecca..ebb4e492a8 100644 --- a/cli/internal/identitybroker/README.md +++ b/cli/internal/identitybroker/README.md @@ -86,8 +86,9 @@ A member can issue a signed revocation for its own credential. Revocation is durable and does not expire. The verifier excludes the credential after the revocation time and rejects memberships sponsored after a sponsor was revoked. -A broker refuses to sponsor with a locally known revoked credential. When -finalizing a certificate, it also includes its already-known artifacts for that +A broker commits a one-party self-revocation atomically when it issues the sole +approval, and then refuses to sponsor with that credential. When finalizing +other certificates, it also includes its already-known artifacts for that group. A caller therefore cannot make that broker ignore a local revocation by skipping finalization there or omitting it from the supporting bundle. The PoC does not yet define quorum removal, group recovery, or group epochs. diff --git a/cli/internal/identitybroker/broker.go b/cli/internal/identitybroker/broker.go index d124e5a034..d10eeb2fb6 100644 --- a/cli/internal/identitybroker/broker.go +++ b/cli/internal/identitybroker/broker.go @@ -115,7 +115,9 @@ func (b *Broker) IssueChallenge(account Account, kind, role string, ceremonyPubl } // Approve validates and consumes the authenticated account's challenge, or -// returns the same approval for an idempotent retry of the same statement. +// returns the same approval for an idempotent retry of the same statement. A +// one-party self-revocation is complete at this point and is committed before +// the approval is returned, so the client cannot withhold a later finalization. func (b *Broker) Approve(authenticated Account, request CeremonyRequest, now time.Time) (Approval, error) { if authenticated.Origin != b.origin { return Approval{}, ErrChallengeMismatch @@ -181,6 +183,13 @@ func (b *Broker) Approve(authenticated Account, request CeremonyRequest, now tim } b.approvals[participant.ChallengeID] = issuedApproval{statementID: statementID, approval: approval} delete(b.challenges, participant.ChallengeID) + if request.Statement.Kind == KindRevocation { + b.revoked[request.Statement.RevokedCredentialID] = struct{}{} + b.certificates[statementID] = cloneCertificate(Certificate{ + Request: request, + Approvals: []Approval{approval}, + }) + } return cloneApproval(approval), nil } diff --git a/cli/internal/identitybroker/protocol_test.go b/cli/internal/identitybroker/protocol_test.go index d9f5f35b1d..4a98c369a4 100644 --- a/cli/internal/identitybroker/protocol_test.go +++ b/cli/internal/identitybroker/protocol_test.go @@ -347,6 +347,7 @@ func TestMembershipSponsoredAfterRevocationIsRejected(t *testing.T) { t.Fatal(err) } firstCredentialID := CredentialID(genesisID, first.account) + membership := makeMembership(t, groupID, third, first, second, founderRefs(t, genesis, first, second), testNow.Add(2*time.Second)) revokedAt := testNow.Add(time.Second) publicKey, privateKey, err := NewCeremonyKey() if err != nil { @@ -363,7 +364,6 @@ func TestMembershipSponsoredAfterRevocationIsRejected(t *testing.T) { t.Fatal(err) } revocation := signWithMembers(t, revocationStatement, privateKey, revokedAt, first) - membership := makeMembership(t, groupID, third, first, second, founderRefs(t, genesis, first, second), testNow.Add(2*time.Second)) if _, err := verifier.Reconstruct([]Certificate{membership, revocation, genesis}, testNow.Add(3*time.Second)); !errors.Is(err, ErrInsufficientSponsors) { t.Fatalf("Reconstruct error = %v, want revoked sponsor rejection", err) @@ -539,10 +539,7 @@ func TestSponsorApprovalRejectsKnownRevocationBeforeSelectiveFinalization(t *tes if err != nil { t.Fatal(err) } - revocation := signWithMembers(t, revocationStatement, privateKey, revokedAt, first) - if err := first.broker.Finalize(revocation, verifier, []Certificate{genesis}, revokedAt); err != nil { - t.Fatal(err) - } + signWithMembers(t, revocationStatement, privateKey, revokedAt, first) publicKey, privateKey, err = NewCeremonyKey() if err != nil { From 738fa68d73f40484dd33bfc43f13929ca49fab86 Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Tue, 14 Jul 2026 01:00:17 +0200 Subject: [PATCH 5/6] fix(identity): validate revocation credentials --- cli/internal/identitybroker/broker.go | 17 ++++- cli/internal/identitybroker/protocol_test.go | 74 ++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/cli/internal/identitybroker/broker.go b/cli/internal/identitybroker/broker.go index d10eeb2fb6..df1d0c3f6b 100644 --- a/cli/internal/identitybroker/broker.go +++ b/cli/internal/identitybroker/broker.go @@ -34,6 +34,7 @@ type Broker struct { challenges map[string]Challenge approvals map[string]issuedApproval certificates map[string]Certificate + credentials map[string]Credential revoked map[string]struct{} } @@ -56,6 +57,7 @@ func NewBroker(origin string) (*Broker, error) { challenges: map[string]Challenge{}, approvals: map[string]issuedApproval{}, certificates: map[string]Certificate{}, + credentials: map[string]Credential{}, revoked: map[string]struct{}{}, }, nil } @@ -169,6 +171,12 @@ func (b *Broker) Approve(authenticated Account, request CeremonyRequest, now tim } } } + if request.Statement.Kind == KindRevocation { + credential, ok := b.credentials[request.Statement.RevokedCredentialID] + if !ok || credential.GroupID != request.Statement.GroupID || credential.Account != authenticated || !credential.activeAt(now.Unix()) { + return Approval{}, fmt.Errorf("%w: revocation does not name an active local credential", ErrInvalidArtifact) + } + } signature, err := signApproval(request.Statement, participant.Role, authenticated, b.privateKey) if err != nil { @@ -185,6 +193,9 @@ func (b *Broker) Approve(authenticated Account, request CeremonyRequest, now tim delete(b.challenges, participant.ChallengeID) if request.Statement.Kind == KindRevocation { b.revoked[request.Statement.RevokedCredentialID] = struct{}{} + credential := b.credentials[request.Statement.RevokedCredentialID] + credential.RevokedAt = request.Statement.IssuedAt + b.credentials[credential.ID] = credential b.certificates[statementID] = cloneCertificate(Certificate{ Request: request, Approvals: []Approval{approval}, @@ -212,7 +223,8 @@ func (b *Broker) Finalize(certificate Certificate, verifier *Verifier, supportin } bundle := append(known, supporting...) bundle = append(bundle, certificate) - if _, err := verifier.Reconstruct(bundle, now); err != nil { + group, err := verifier.Reconstruct(bundle, now) + if err != nil { return err } if existing, ok := b.certificates[statementID]; ok { @@ -225,6 +237,9 @@ func (b *Broker) Finalize(certificate Certificate, verifier *Verifier, supportin if certificate.Request.Statement.Kind == KindRevocation { b.revoked[certificate.Request.Statement.RevokedCredentialID] = struct{}{} } + for id, credential := range group.Credentials { + b.credentials[id] = credential + } b.certificates[statementID] = cloneCertificate(certificate) return nil } diff --git a/cli/internal/identitybroker/protocol_test.go b/cli/internal/identitybroker/protocol_test.go index 4a98c369a4..19956e118e 100644 --- a/cli/internal/identitybroker/protocol_test.go +++ b/cli/internal/identitybroker/protocol_test.go @@ -305,6 +305,9 @@ func TestRevocationRemovesMember(t *testing.T) { verifier := NewVerifier(trustMembers(t, first, second, third)) genesis, groupID := makeGenesis(t, first, second, testNow) membership := makeMembership(t, groupID, third, first, second, founderRefs(t, genesis, first, second), testNow.Add(time.Second)) + if err := third.broker.Finalize(membership, verifier, []Certificate{genesis}, testNow.Add(time.Second)); err != nil { + t.Fatal(err) + } membershipID, err := StatementID(membership.Request.Statement) if err != nil { t.Fatal(err) @@ -342,6 +345,9 @@ func TestMembershipSponsoredAfterRevocationIsRejected(t *testing.T) { third := newTestMember(t, "https://three.example", "user-three") verifier := NewVerifier(trustMembers(t, first, second, third)) genesis, groupID := makeGenesis(t, first, second, testNow) + if err := first.broker.Finalize(genesis, verifier, nil, testNow); err != nil { + t.Fatal(err) + } genesisID, err := StatementID(genesis.Request.Statement) if err != nil { t.Fatal(err) @@ -569,3 +575,71 @@ func TestSponsorApprovalRejectsKnownRevocationBeforeSelectiveFinalization(t *tes t.Fatalf("Approve error = %v, want known-revocation rejection", err) } } + +func TestRevocationApprovalRequiresActiveOwnedCredential(t *testing.T) { + tests := []struct { + name string + credentialID func(string, testMember, testMember) string + at time.Time + }{ + { + name: "unknown", + credentialID: func(_ string, _, _ testMember) string { return "unknown-credential" }, + at: testNow.Add(time.Second), + }, + { + name: "wrong account", + credentialID: func(genesisID string, _ testMember, second testMember) string { + return CredentialID(genesisID, second.account) + }, + at: testNow.Add(time.Second), + }, + { + name: "expired", + credentialID: func(genesisID string, first, _ testMember) string { + return CredentialID(genesisID, first.account) + }, + at: testNow.Add(25 * time.Hour), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + first := newTestMember(t, "https://one.example", "user-one") + second := newTestMember(t, "https://two.example", "user-two") + verifier := NewVerifier(trustMembers(t, first, second)) + genesis, groupID := makeGenesis(t, first, second, testNow) + if err := first.broker.Finalize(genesis, verifier, nil, testNow); err != nil { + t.Fatal(err) + } + genesisID, err := StatementID(genesis.Request.Statement) + if err != nil { + t.Fatal(err) + } + publicKey, privateKey, err := NewCeremonyKey() + if err != nil { + t.Fatal(err) + } + statement, err := NewRevocationStatement( + groupID, + test.credentialID(genesisID, first, second), + issueChallenge(t, first, KindRevocation, RoleMember, publicKey, test.at), + publicKey, + test.at, + ) + if err != nil { + t.Fatal(err) + } + request, err := SignCeremony(statement, privateKey) + if err != nil { + t.Fatal(err) + } + if _, err := first.broker.Approve(first.account, request, test.at); !errors.Is(err, ErrInvalidArtifact) { + t.Fatalf("Approve error = %v, want invalid revocation rejection", err) + } + if got := len(first.broker.Certificates()); got != 1 { + t.Fatalf("stored certificates = %d, want only genesis", got) + } + }) + } +} From 2f0a771fd71135a4fc9153a2eb580392789781c7 Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Tue, 14 Jul 2026 01:01:41 +0200 Subject: [PATCH 6/6] fix(identity): validate revocation issuance time --- cli/internal/identitybroker/broker.go | 2 +- cli/internal/identitybroker/protocol_test.go | 40 ++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/cli/internal/identitybroker/broker.go b/cli/internal/identitybroker/broker.go index df1d0c3f6b..6bb2d4deed 100644 --- a/cli/internal/identitybroker/broker.go +++ b/cli/internal/identitybroker/broker.go @@ -173,7 +173,7 @@ func (b *Broker) Approve(authenticated Account, request CeremonyRequest, now tim } if request.Statement.Kind == KindRevocation { credential, ok := b.credentials[request.Statement.RevokedCredentialID] - if !ok || credential.GroupID != request.Statement.GroupID || credential.Account != authenticated || !credential.activeAt(now.Unix()) { + if !ok || credential.GroupID != request.Statement.GroupID || credential.Account != authenticated || !credential.activeAt(request.Statement.IssuedAt) { return Approval{}, fmt.Errorf("%w: revocation does not name an active local credential", ErrInvalidArtifact) } } diff --git a/cli/internal/identitybroker/protocol_test.go b/cli/internal/identitybroker/protocol_test.go index 19956e118e..90e3c46bd2 100644 --- a/cli/internal/identitybroker/protocol_test.go +++ b/cli/internal/identitybroker/protocol_test.go @@ -643,3 +643,43 @@ func TestRevocationApprovalRequiresActiveOwnedCredential(t *testing.T) { }) } } + +func TestRevocationApprovalRejectsCredentialExpiredAtFutureIssuance(t *testing.T) { + first := newTestMember(t, "https://one.example", "user-one") + second := newTestMember(t, "https://two.example", "user-two") + verifier := NewVerifier(trustMembers(t, first, second)) + genesis, groupID := makeGenesis(t, first, second, testNow) + if err := first.broker.Finalize(genesis, verifier, nil, testNow); err != nil { + t.Fatal(err) + } + genesisID, err := StatementID(genesis.Request.Statement) + if err != nil { + t.Fatal(err) + } + publicKey, privateKey, err := NewCeremonyKey() + if err != nil { + t.Fatal(err) + } + approveAt := testNow.Add(24*time.Hour - 30*time.Second) + issuedAt := testNow.Add(24*time.Hour + time.Second) + statement, err := NewRevocationStatement( + groupID, + CredentialID(genesisID, first.account), + issueChallenge(t, first, KindRevocation, RoleMember, publicKey, approveAt), + publicKey, + issuedAt, + ) + if err != nil { + t.Fatal(err) + } + request, err := SignCeremony(statement, privateKey) + if err != nil { + t.Fatal(err) + } + if _, err := first.broker.Approve(first.account, request, approveAt); !errors.Is(err, ErrInvalidArtifact) { + t.Fatalf("Approve error = %v, want expiry-at-issuance rejection", err) + } + if got := len(first.broker.Certificates()); got != 1 { + t.Fatalf("stored certificates = %d, want only genesis", got) + } +}