diff --git a/cli/internal/identitybroker/README.md b/cli/internal/identitybroker/README.md new file mode 100644 index 0000000000..ebb4e492a8 --- /dev/null +++ b/cli/internal/identitybroker/README.md @@ -0,0 +1,182 @@ +# 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. 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 + 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 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 + +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'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. +- 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. + +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. + +## 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. +- 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. + +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. +- 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 +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 | 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 +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. +- 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 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. + +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..6bb2d4deed --- /dev/null +++ b/cli/internal/identitybroker/broker.go @@ -0,0 +1,317 @@ +package identitybroker + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "sync" + "time" +) + +const ( + DefaultChallengeTTL = 5 * time.Minute + MaxCredentialTTL = 30 * 24 * time.Hour +) + +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 + credentials map[string]Credential + revoked map[string]struct{} +} + +// 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{}, + credentials: map[string]Credential{}, + revoked: map[string]struct{}{}, + }, 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, 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) + } + 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) + } + 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 + } + nonce, err := NewOpaqueID(32) + if err != nil { + return Challenge{}, err + } + challenge := Challenge{ + 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] = cloneChallenge(challenge) + b.mu.Unlock() + return cloneChallenge(challenge), nil +} + +// Approve validates and consumes the authenticated account's challenge, or +// 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 + } + 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 !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) + } + 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 + } + } + } + if request.Statement.Kind == KindRevocation { + credential, ok := b.credentials[request.Statement.RevokedCredentialID] + 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) + } + } + + 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) + 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}, + }) + } + 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 !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) + group, err := verifier.Reconstruct(bundle, now) + if err != nil { + return err + } + 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 + } + 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 +} + +// 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 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...) + 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..7bc694050e --- /dev/null +++ b/cli/internal/identitybroker/builders.go @@ -0,0 +1,101 @@ +package identitybroker + +import ( + "crypto/ed25519" + "fmt" + "time" +) + +// NewGenesisStatement constructs the first certificate statement for two +// founders on distinct server origins. +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) + } + participants := make([]Participant, 0, 2) + for _, challenge := range founders { + 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, + 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 + } + 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{ + 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 + } + 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, + 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..ceca995749 --- /dev/null +++ b/cli/internal/identitybroker/integration_test.go @@ -0,0 +1,340 @@ +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"` + CeremonyPublicKey []byte `json:"ceremony_public_key"` +} + +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, request.CeremonyPublicKey, 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(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, ceremonyPublicKey []byte) Challenge { + t.Helper() + 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 { + 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() + publicKey, privateKey, err := NewCeremonyKey() + if err != nil { + t.Fatal(err) + } + 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) + } + request, err := SignCeremony(statement, privateKey) + 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 +} + +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, publicKey), + []Challenge{ + httpChallenge(t, sponsorA, KindMembership, RoleSponsor, publicKey), + httpChallenge(t, sponsorB, KindMembership, RoleSponsor, publicKey), + }, + 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..df008207c1 --- /dev/null +++ b/cli/internal/identitybroker/protocol.go @@ -0,0 +1,223 @@ +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"` + 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 +// 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. +// 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"` +} + +// 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..90e3c46bd2 --- /dev/null +++ b/cli/internal/identitybroker/protocol_test.go @@ -0,0 +1,685 @@ +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.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, ceremonyPublicKey ed25519.PublicKey, now time.Time) Challenge { + t.Helper() + challenge, err := member.broker.IssueChallenge(member.account, kind, role, ceremonyPublicKey, 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() + publicKey, privateKey, err := NewCeremonyKey() + if err != nil { + t.Fatalf("NewCeremonyKey: %v", err) + } + 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) + } + 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 { + t.Helper() + publicKey, privateKey, err := NewCeremonyKey() + if err != nil { + t.Fatalf("NewCeremonyKey: %v", err) + } + statement, err := NewMembershipStatement( + groupID, + issueChallenge(t, target, KindMembership, RoleTarget, publicKey, now), + []Challenge{ + issueChallenge(t, sponsorA, KindMembership, RoleSponsor, publicKey, now), + issueChallenge(t, sponsorB, KindMembership, RoleSponsor, publicKey, 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) && !errors.Is(err, ErrInvalidArtifact) { + t.Fatalf("VerifyCertificate error = %v, want tampering rejection", 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") + publicKey, _, err := NewCeremonyKey() + if err != nil { + t.Fatal(err) + } + _, 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) + } + 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") + publicKey, privateKey, 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) + } + 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") + publicKey, privateKey, 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) + } + 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"} + publicKey, _, err := NewCeremonyKey() + if err != nil { + t.Fatal(err) + } + secondChallenge, err := sponsor.broker.IssueChallenge(secondSponsorAccount, KindMembership, RoleSponsor, publicKey, testNow) + if err != nil { + t.Fatal(err) + } + _, err = NewMembershipStatement( + "group", + issueChallenge(t, target, KindMembership, RoleTarget, publicKey, testNow), + []Challenge{ + issueChallenge(t, sponsor, KindMembership, RoleSponsor, publicKey, 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)) + 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) + } + 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, publicKey, 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) + 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) + } + 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 { + t.Fatal(err) + } + revocationStatement, err := NewRevocationStatement( + groupID, + firstCredentialID, + issueChallenge(t, first, KindRevocation, RoleMember, publicKey, revokedAt), + publicKey, + revokedAt, + ) + if err != nil { + t.Fatal(err) + } + revocation := signWithMembers(t, revocationStatement, privateKey, revokedAt, first) + + 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) + } +} + +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 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") + 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) + } + signWithMembers(t, revocationStatement, privateKey, revokedAt, first) + + 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) + } +} + +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) + } + }) + } +} + +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) + } +} diff --git a/cli/internal/identitybroker/trust.go b/cli/internal/identitybroker/trust.go new file mode 100644 index 0000000000..a116ced399 --- /dev/null +++ b/cli/internal/identitybroker/trust.go @@ -0,0 +1,55 @@ +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 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) + } + 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..6720902e68 --- /dev/null +++ b/cli/internal/identitybroker/validate.go @@ -0,0 +1,150 @@ +package identitybroker + +import ( + "crypto/ed25519" + "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 statement.Kind != KindGenesis && 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) + } + 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) + 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.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) + } + 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..64db72bfda --- /dev/null +++ b/cli/internal/identitybroker/verifier.go @@ -0,0 +1,268 @@ +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{}{} + for _, certificate := range certificates { + certificateID, err := v.VerifyCertificate(certificate) + if err != nil { + return nil, err + } + if _, exists := seenIDs[certificateID]; exists { + continue + } + seenIDs[certificateID] = struct{}{} + 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{}, + } + + pendingMemberships := make([]verifiedCertificate, 0) + revocations := make([]verifiedCertificate, 0) + for _, record := range records { + statement := record.certificate.Request.Statement + switch statement.Kind { + case KindGenesis: + 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) + } + } + 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) + } + } + + credentialsByAccount := map[string][]Credential{} + for _, credential := range group.Credentials { + accountKey := credential.Account.key() + credentialsByAccount[accountKey] = append(credentialsByAccount[accountKey], credential) + if credential.activeAt(now.Unix()) { + 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 + } + 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 +}