From ec9c31331e400172f01b0fb27e594b4dcb858eb6 Mon Sep 17 00:00:00 2001 From: Alex Au Date: Sun, 9 Aug 2026 01:28:14 +0800 Subject: [PATCH 1/2] feat: rebuild eventctl for v3 artifacts --- .github/CODEOWNERS | 1 + .github/workflows/ci.yml | 2 +- README.md | 251 ++--- SECURITY.md | 49 +- cmd/eventctl/main.go | 521 +++------- docs/architecture.md | 104 -- internal/buildinfo/buildinfo.go | 4 +- internal/protocol/protocol.go | 1163 ---------------------- internal/stream/stream.go | 133 --- mise.toml | 1 + protocol/protocol.go | 833 ++++++++++++++++ tests/e2e/eventctl_test.go | 1589 +++++++++++++------------------ 12 files changed, 1723 insertions(+), 2928 deletions(-) delete mode 100644 docs/architecture.md delete mode 100644 internal/protocol/protocol.go delete mode 100644 internal/stream/stream.go create mode 100644 protocol/protocol.go diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 349d651..77c420e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,5 +3,6 @@ /.github/ @alex-au-922 @DebugTsang @sammyfung /cmd/ @alex-au-922 @DebugTsang @sammyfung /internal/ @alex-au-922 @DebugTsang @sammyfung +/protocol/ @alex-au-922 @DebugTsang @sammyfung /go.mod @alex-au-922 @DebugTsang @sammyfung /go.sum @alex-au-922 @DebugTsang @sammyfung diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 284ebf1..764001b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: go mod download go mod tidy -diff go mod verify - test -z "$(gofmt -l cmd internal tests)" + test -z "$(gofmt -l cmd internal protocol tests)" - name: Run static checks run: go vet ./... diff --git a/README.md b/README.md index 015a525..8729e52 100644 --- a/README.md +++ b/README.md @@ -1,211 +1,138 @@ # eventctl -`eventctl` is the small, offline CLI behind reusable PythonHK event -repositories. It creates participant keys, signs event-bound registrations, -team requests, and submissions, and encrypts result files for a team. +`eventctl` is a small offline CLI for GitHub-native PythonHK events. It makes +portable team and submission artifacts, and signs and encrypts participant +feedback. It deliberately has no GitHub client, registry mutation, policy +engine, GitHub App, PAT, shell runtime, or organizer private key. -It has no GitHub App, PEM, GitHub token, network client, Git mutation, or -organizer private key. The event repository supplies the trusted public event -binding and its protected `registry` branch supplies the authoritative state. +The repository workflow owns actor authentication, protected state, admission, +and scoring. `eventctl` owns participant keys and the `eventctl/v3` artifact +format. ## Commands ```text eventctl version -eventctl doctor [--event BINDING --registry REGISTRY] -eventctl key-gen --out DIR --passphrase-file PATH +eventctl doctor +eventctl key-gen --out DIR -eventctl identity register|verify ... -eventctl team propose|consent|verify ... -eventctl submission prepare|verify ... +eventctl team form --event-id ID --team-name NAME --github-id ID --member ID... \ + --sig-priv-key PATH --enc-pub-key PATH --out formation.tar +eventctl team key --formation formation.tar --github-id ID \ + --sig-priv-key PATH --enc-pub-key PATH --out key.tar -eventctl sigcrypt ... -eventctl decverify ... +eventctl submission prepare --event-id ID --github-id ID --team-id UUID \ + --input PATH... --sig-priv-key PATH --out submission.tar + +eventctl sigcrypt --input PATH... --out feedback.tar \ + --sig-priv-key PATH --enc-pub-key PATH... +eventctl decverify --input feedback.tar --out-dir DIR \ + --sig-pub-key PATH --enc-priv-key PATH ``` -Every command writes exactly one JSON response to standard output. A successful -response has `ok: true`; a rejected request has `ok: false` and a non-empty -`error`. The process exits nonzero for the latter. +Every command writes one JSON response to standard output. A rejected command +has `ok: false` and exits nonzero. `doctor` can additionally validate an +optional `EVENTCTL_EVENT_ID=owner/repository` environment value. -## The two-branch model +## Keys -```text -main public event binding, workflows, and merged participant requests -registry protected authoritative identities, activated teams, and attempts -``` +`key-gen` creates independent key pairs with no passphrase wrapper: -Participants work from forks and open pull requests to `main`. The normal -read-only workflow checks the GitHub actor and request shape. An organizer then -uses `eventctl` with the immutable GitHub creation time and writes the accepted -result through a reviewed PR to `registry`. No participant request changes -state by itself. - -`event/binding.json` is public, reviewed policy. It includes the event and -repository IDs, event epoch, validity window, terms digest, request TTLs, and -team/attempt limits. Every signed document embeds its derived event reference, -so it cannot be replayed into a different event, repository, or policy -revision. - -The single registry document is also event-bound: - -```json -{ - "v": 2, - "kind": "event-registry", - "event": { "event_id": "...", "event_epoch": 1, "repository_id": "...", "binding_sha256": "..." }, - "revision": 0, - "phase": "formation_open", - "enabled": true, - "disabled_reason": "", - "identities": [], - "teams": [], - "attempts": [] -} +```text +DIR/ +├── signature/ +│ ├── key Ed25519 PKCS#8 PEM private key (0600) +│ └── key.pub Ed25519 PKIX PEM public key +└── encryption/ + ├── key age hybrid ML-KEM768/X25519 private identity (0600) + └── key.pub age hybrid recipient public key ``` -The valid phases are `draft`, `registration_open`, `formation_open`, -`submissions_open`, and `closed`. `eventctl` requires `formation_open` for -team work and `submissions_open` for admission. It rejects an enabled registry -with a disabled reason, or a disabled registry without one. +The signing key proves who made an artifact. The encryption key decrypts +feedback. They are intentionally distinct. -## One participant setup +## Team onboarding -```bash -eventctl key-gen \ - --out participant-identity \ - --passphrase-file passphrase.txt -``` - -This creates four files: +GitHub IDs are numeric account IDs. `team form` generates a UUIDv4 team ID, +prints it in its response, and requires its initiator to appear in the sorted +`--member` set. Team names are free-form visible names. ```text -signing.private.age encrypted Ed25519 signing key -signing.public.json Ed25519 verification document -recipient.private.age encrypted age hybrid decryption identity -recipient.public.json age hybrid recipient document +one person: formation.tar +two or more: formation.tar + one key.tar from every other listed member ``` -The two key types remain separate: Ed25519 signs documents, while age hybrid -ML-KEM768/X25519 decrypts team data. One passphrase protects both local private -files for convenience; the passphrase is only read from a file. +`formation.tar` self-proves the initiator's signing and encryption public +keys. A solo formation is therefore sufficient for a trusted controller to +activate that team. For a multi-member team, each additional person produces a +`key.tar` tied to the formation tar's SHA-256 digest. There is no captain +field, registry mutation, or GitHub integration in the CLI. -## Registration and team formation +## Artifacts -Registration is self-signed, but it becomes active only when its verified -record is reviewed into `registry.identities`. The `--source-time` supplied to -verification must be a trusted immutable time, normally GitHub's pull-request -creation time—not a participant commit timestamp. +Public request artifacts are plain deterministic tar archives. Each contains a +signed `manifest.json`; submission tars also contain sorted `files/` +entries. The manifest binds the protocol, event slug, numeric actor ID, team +UUID, generated attempt UUID when applicable, public signing key, and file +digests. -```bash -eventctl identity register \ - --event event/binding.json --actor-id 12345 \ - --sig-private-key participant-identity/signing.private.age \ - --recipient-public-key participant-identity/recipient.public.json \ - --passphrase-file passphrase.txt --output registration.json - -eventctl identity verify \ - --event event/binding.json --input registration.json \ - --expect-actor-id 12345 --source-time 2026-08-08T12:00:00Z \ - --output identity-record.json -``` +`sigcrypt` creates `feedback.tar`, an outer tar with exactly: -An active member proposes a sorted team from the protected registry. Every -listed member, including the proposer, separately signs their consent. The -verifier resolves every signing and recipient key from the active registry; -keys claimed only in the request are never trusted. - -```bash -eventctl team propose \ - --event event/binding.json --registry registry/state.json \ - --actor-id 12345 --member 12345 --member 67890 \ - --sig-private-key participant-identity/signing.private.age \ - --passphrase-file passphrase.txt --output proposal.json - -eventctl team consent \ - --event event/binding.json --registry registry/state.json \ - --proposal proposal.json --actor-id 67890 \ - --sig-private-key teammate-identity/signing.private.age \ - --passphrase-file teammate-passphrase.txt --output consent.json - -eventctl team verify \ - --event event/binding.json --registry registry/state.json \ - --proposal proposal.json --proposal-source-time 2026-08-08T12:00:00Z \ - --consent captain-consent.json --consent consent.json \ - --consent-source-time 12345=2026-08-08T12:01:00Z \ - --consent-source-time 67890=2026-08-08T12:02:00Z \ - --output verified-team.json +```text +manifest.json +payload.age ``` -The organizer records the verifier's team ID, proposal digest, and members as -one `registry.teams` entry. The registry prevents another active team from -reusing that team ID or any member. +The manifest is signed with Ed25519. `payload.age` is one age-encrypted inner +tar and can be decrypted by any listed recipient key. Age encryption is +intentionally randomized; the surrounding tar layout and manifest encoding are +canonical. `decverify` verifies the trusted signer before safely extracting to +an initially empty directory. -## Submission admission +## Repository contract -Participants prepare an event-bound, signed request alongside the exact -submission file. `prepare` has no state effect; `verify` is the admission -operation used from the protected registry workflow. +The reusable starter uses these branch roles: -```bash -eventctl submission prepare \ - --event event/binding.json --input exploit-package.zip \ - --metadata metadata.json --team-id TEAM_UUID --attempt-id ATTEMPT_UUID \ - --actor-id 12345 --sig-private-key participant-identity/signing.private.age \ - --passphrase-file passphrase.txt --output submission.json - -eventctl submission verify \ - --event event/binding.json --registry registry/state.json \ - --request submission.json --bundle exploit-package.zip --metadata metadata.json \ - --expect-actor-id 12345 --source-time 2026-08-08T12:10:00Z \ - --output verified-submission.json +```text +main participant-facing material and workflows +registry protected trusted team state +leaderboard derived public scores ``` -Verification requires an active team, an active member with the same key -epoch, an unused attempt ID, and remaining per-team and total quotas. The -reviewed registry update records the attempt; it is the durable replay guard. - -## Encrypted result artifacts - -`sigcrypt` signs a bounded byte stream and encrypts it to one or more team -recipient public keys. A GitHub Actions judge can upload the ciphertext as an -artifact. Any intended team member can download it and run `decverify` using -their own recipient private key. +The trusted controller stores only this team state on `registry`: -```bash -eventctl sigcrypt \ - --input judge.log --output judge.log.eventctl --context stream-binding.json \ - --sig-private-key organizer/signing.private.age \ - --passphrase-file organizer-passphrase.txt \ - --enc-public-key captain/recipient.public.json \ - --enc-public-key teammate/recipient.public.json - -eventctl decverify \ - --input judge.log.eventctl --output judge.log --context stream-binding.json \ - --ver-public-key organizer/signing.public.json \ - --dec-private-key teammate/recipient.private.age \ - --passphrase-file teammate-passphrase.txt +```text +teams// + team.json + state.json + .sig.pub + .enc.pub ``` -The stream header signs the event reference, stream purpose, signer, sorted -recipient key IDs, payload size, and SHA-256 digest. Input files are capped at -64 MiB and outputs are exclusive creates, so an existing file is never -overwritten silently. +`team.json` is immutable: ID, free-form name, and sorted numeric members. +`state.json` holds mutable operational provenance, attempts, status, and score +state. An organizer can set a team status to `disabled`; `eventctl` never makes +that decision. ## Development -The repository has one test entrypoint: +The sole test entry point is: ```bash mise run test ``` -It builds an instrumented `eventctl` binary and drives it as a real subprocess -through the complete registration, team, submission, stream, error, replay, -and quota lifecycle. It then merges `GOCOVERDIR` data and fails unless -statement coverage is exactly 100.0%. There are no unit-test tasks. +It compiles the CLI with coverage instrumentation and drives it only as a +subprocess. The suite creates real keys and artifacts, including malformed tar +fixtures and independently signed encrypted feedback, and requires exactly +100.0% Go statement coverage. There are no unit-test tasks or tracked shell +scripts. + +The reusable workflow controller imports the public Go package: -`mise run format-code` formats Go sources. CI runs the same `mise run test` -entrypoint rather than a separate test command. +```text +github.com/pythonhk/eventctl/protocol +``` ## License diff --git a/SECURITY.md b/SECURITY.md index 5f0f73b..a7e469f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,31 +1,30 @@ # Security policy -`eventctl` handles participant signing keys, event-bound attestations, -protected-registry verification, and encrypted event byte streams. Please -report vulnerabilities privately through the repository's GitHub Security tab. - +Report a vulnerability privately through the repository's GitHub Security tab. Include the affected version, command, platform, synthetic reproduction, and -whether confidentiality, signature verification, actor/team binding, replay -safety, or output integrity is affected. Never include real private keys, -plaintext submissions, GitHub tokens, or event secrets. +the impact on artifact integrity, signer verification, recipient +confidentiality, or safe extraction. Never include real private keys, plaintext +submissions, decrypted feedback, tokens, or event secrets. -## Operational boundary +## Security boundary -- Keep encrypted private key files outside event repository checkouts when - possible, with user-only filesystem permissions. -- Do not commit private keys, plaintext event logs, decrypted outputs, or - passphrase files. -- Verify the public event binding through trusted `main`, and consume registry - state only from its protected branch. -- Treat decrypted participant data as untrusted input and execute it only in - the event's isolated scoring boundary. -- Treat GitHub actor and immutable request-creation time as repository inputs; - they are not claims a participant can sign for themselves. -- The CLI does not authenticate GitHub requests, update protected state, or - replace the organizer's reviewed registry transition. It verifies that the - supplied state enforces active membership, replay, and quota rules. -- There is no GitHub App, PEM, GitHub token, or network client in normal - eventctl operation. +- `eventctl` is offline. It does not authenticate GitHub users, call GitHub, + mutate a registry, enforce organizer policy, or run submitted code. +- GitHub Actions binds a request manifest's numeric `github_id` to the account + that opened its pull request. The protected controller verifies this before + accepting an artifact. +- Team membership, names, and registered public keys become immutable when a + team is activated. Operational status and scoring remain trusted registry + state. +- Ed25519 signing and age hybrid ML-KEM768/X25519 encryption use independent + key pairs. Do not reuse, commit, or share either private key. +- Treat every decrypted feedback file and submitted artifact as untrusted data. + Only an event-specific isolated scorer may execute it. +- `decverify` extracts only flat `files/` entries into an initially empty + directory. Do not bypass that boundary by manually unpacking untrusted tars. +- A public Actions artifact is not a confidentiality boundary. Upload only + feedback encrypted to the team's registered encryption public keys, never raw + scorer logs. -Only the current protocol release is supported. Event repositories should pin -the exact binary version and review protocol compatibility before upgrading. +Only the current `eventctl/v3` protocol is supported. Event repositories +should pin the exact released CLI and review protocol changes before upgrading. diff --git a/cmd/eventctl/main.go b/cmd/eventctl/main.go index d0fd273..8131f57 100644 --- a/cmd/eventctl/main.go +++ b/cmd/eventctl/main.go @@ -1,6 +1,7 @@ package main import ( + "crypto/ed25519" "encoding/json" "errors" "fmt" @@ -8,15 +9,14 @@ import ( "os" "runtime" "strings" - "time" "filippo.io/age" "github.com/caarlos0/env/v11" + "github.com/samber/lo" "github.com/spf13/cobra" "github.com/pythonhk/eventctl/internal/buildinfo" - "github.com/pythonhk/eventctl/internal/protocol" - "github.com/pythonhk/eventctl/internal/stream" + "github.com/pythonhk/eventctl/protocol" ) type response struct { @@ -27,21 +27,20 @@ type response struct { } type doctorEnvironment struct { - EventID string `env:"EVENTCTL_EVENT_ID" envDefault:"unset"` - EventEpoch int `env:"EVENTCTL_EVENT_EPOCH" envDefault:"1"` + EventID string `env:"EVENTCTL_EVENT_ID" envDefault:""` } func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } -func run(args []string, stdout, stderr io.Writer) int { +func run(arguments []string, stdout, stderr io.Writer) int { root := newRoot(stdout, stderr) - root.SetArgs(args) + root.SetArgs(arguments) if err := root.Execute(); err != nil { command := strings.TrimSpace(strings.TrimPrefix(root.CommandPath(), "eventctl")) if command == "" { command = "eventctl" } - _ = writeResponse(stdout, response{OK: false, Command: command, Error: err.Error()}) + writeResponse(stdout, response{OK: false, Command: command, Error: err.Error()}) return 1 } return 0 @@ -50,13 +49,13 @@ func run(args []string, stdout, stderr io.Writer) int { func newRoot(stdout, stderr io.Writer) *cobra.Command { root := &cobra.Command{ Use: "eventctl", - Short: "PythonHK event registration and byte-stream cryptography", + Short: "portable event artifacts and encrypted feedback", SilenceErrors: true, SilenceUsage: true, } root.SetOut(stdout) root.SetErr(stderr) - root.AddCommand(versionCommand(), doctorCommand(), keyGenCommand(), sigcryptCommand(), decverifyCommand(), identityCommand(), teamCommand(), submissionCommand()) + root.AddCommand(versionCommand(), doctorCommand(), keyGenCommand(), teamCommand(), submissionCommand(), sigcryptCommand(), decverifyCommand()) return root } @@ -67,477 +66,227 @@ func versionCommand() *cobra.Command { } func doctorCommand() *cobra.Command { - var eventPath, registryPath string - command := &cobra.Command{Use: "doctor", Short: "check local eventctl configuration and protocol algorithms", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { - if eventPath != "" { - binding, err := protocol.ReadEventBinding(eventPath) - if err != nil { + return &cobra.Command{Use: "doctor", Short: "check local eventctl support", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + configuration := lo.Must(env.ParseAs[doctorEnvironment]()) + if configuration.EventID != "" { + if err := protocol.ValidateEventID(configuration.EventID); err != nil { return err } - result := map[string]any{"protocol": protocol.Protocol, "event": binding.Reference(), "go": runtime.Version(), "algorithms": map[string]string{"signing": "Ed25519", "recipient": "age-hybrid-mlkem768-x25519"}} - if registryPath != "" { - registry, readErr := protocol.ReadRegistry(registryPath, binding) - if readErr != nil { - return readErr - } - result["registry"] = map[string]any{"revision": registry.Revision, "phase": registry.Phase, "enabled": registry.Enabled, "identities": len(registry.Identities), "teams": len(registry.Teams), "attempts": len(registry.Attempts)} - } - return emit(command, result) - } - if registryPath != "" { - return errors.New("--registry requires --event") - } - var configuration doctorEnvironment - if err := env.Parse(&configuration); err != nil { - return fmt.Errorf("parse environment: %w", err) } - if configuration.EventEpoch < 1 { - return errors.New("EVENTCTL_EVENT_EPOCH must be positive") - } - return emit(command, map[string]any{"protocol": protocol.Protocol, "event_id": configuration.EventID, "event_epoch": configuration.EventEpoch, "go": runtime.Version(), "algorithms": map[string]string{"signing": "Ed25519", "recipient": "age-hybrid-mlkem768-x25519"}}) + return emit(command, map[string]any{ + "protocol": protocol.Version, + "event_id": configuration.EventID, + "go": runtime.Version(), + "algorithms": map[string]string{ + "signature": "Ed25519", + "encryption": "age-hybrid-mlkem768-x25519", + }, + }) }} - command.Flags().StringVar(&eventPath, "event", "", "public event binding JSON") - command.Flags().StringVar(®istryPath, "registry", "", "protected event registry JSON") - return command } func keyGenCommand() *cobra.Command { - var directory, passphraseFile string - command := &cobra.Command{Use: "key-gen", Short: "create signing and recipient key pairs", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { - passphrase, err := readPassphrase(passphraseFile) + var output string + command := &cobra.Command{Use: "key-gen", Short: "create signing and encryption key pairs", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + paths, err := protocol.GenerateKeyDirectory(output) if err != nil { return err } - value, err := protocol.GenerateKeyDirectory(directory, passphrase) - if err != nil { - return err - } - return emit(command, value) + return emit(command, paths) }} - command.Flags().StringVar(&directory, "out", "", "key directory") - command.Flags().StringVar(&passphraseFile, "passphrase-file", "", "key passphrase file") + command.Flags().StringVar(&output, "out", "", "key directory") require(command, "out") return command } -func sigcryptCommand() *cobra.Command { - var input, output, context, signingPath, passphraseFile string - var recipientPaths []string - command := &cobra.Command{Use: "sigcrypt", Short: "sign and encrypt one byte stream", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { - binding, err := protocol.ReadStreamBinding(context) +func teamCommand() *cobra.Command { + root := &cobra.Command{Use: "team", Short: "prepare one-off team onboarding artifacts"} + root.AddCommand(teamFormCommand(), teamKeyCommand()) + return root +} + +func teamFormCommand() *cobra.Command { + var eventID, teamName, githubID, signingPath, encryptionPath, output string + var members []string + command := &cobra.Command{Use: "form", Short: "form a peer team and generate its UUID", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + private, err := protocol.LoadSigningPrivate(signingPath) if err != nil { return err } - passphrase, err := readPassphrase(passphraseFile) + public := publicPEM(private) + encryption, err := protocol.LoadEncryptionPublic(encryptionPath) if err != nil { return err } - signingKey, err := protocol.LoadSigningPrivate(signingPath, passphrase) + manifest, err := protocol.NewFormation(eventID, githubID, teamName, public, encryption.Text, members) if err != nil { return err } - recipients := make([]protocol.RecipientPublic, 0, len(recipientPaths)) - identities := make([]age.Recipient, 0, len(recipientPaths)) - seen := make(map[string]bool, len(recipientPaths)) - for _, path := range recipientPaths { - recipient, loadErr := protocol.LoadRecipientPublic(path) - if loadErr != nil { - return loadErr - } - if seen[recipient.KeyID] { - continue - } - seen[recipient.KeyID] = true - recipients = append(recipients, recipient) - identities = append(identities, recipient.Recipient) - } - value, err := stream.SealFile(input, output, binding, signingKey, recipients, identities) + result, err := protocol.WriteArtifact(output, manifest, nil, private) if err != nil { return err } - return emit(command, value) + return emit(command, result) }} - command.Flags().StringVar(&input, "input", "", "input byte stream") - command.Flags().StringVar(&output, "output", "", "encrypted output") - command.Flags().StringVar(&context, "context", "", "stream binding JSON") - command.Flags().StringVar(&signingPath, "sig-private-key", "", "encrypted signing key") - command.Flags().StringVar(&passphraseFile, "passphrase-file", "", "signing key passphrase file") - command.Flags().StringArrayVar(&recipientPaths, "enc-public-key", nil, "recipient public key (repeatable)") - require(command, "input", "output", "context", "sig-private-key") + command.Flags().StringVar(&eventID, "event-id", "", "event repository slug") + command.Flags().StringVar(&teamName, "team-name", "", "free-form team name") + command.Flags().StringVar(&githubID, "github-id", "", "numeric GitHub account ID") + command.Flags().StringArrayVar(&members, "member", nil, "numeric GitHub member ID (repeatable)") + command.Flags().StringVar(&signingPath, "sig-priv-key", "", "Ed25519 private key") + command.Flags().StringVar(&encryptionPath, "enc-pub-key", "", "age recipient public key") + command.Flags().StringVar(&output, "out", "", "formation tar output") + require(command, "event-id", "team-name", "github-id", "sig-priv-key", "enc-pub-key", "out") return command } -func decverifyCommand() *cobra.Command { - var input, output, context, signerPath, recipientPath, passphraseFile string - command := &cobra.Command{Use: "decverify", Short: "decrypt and verify one byte stream", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { - binding, err := protocol.ReadStreamBinding(context) +func teamKeyCommand() *cobra.Command { + var formationPath, githubID, signingPath, encryptionPath, output string + command := &cobra.Command{Use: "key", Short: "prove one additional member key", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + formation, err := protocol.ReadArtifact(formationPath) if err != nil { return err } - signer, err := protocol.LoadSigningPublic(signerPath) + private, err := protocol.LoadSigningPrivate(signingPath) if err != nil { return err } - passphrase, err := readPassphrase(passphraseFile) + public := publicPEM(private) + encryption, err := protocol.LoadEncryptionPublic(encryptionPath) if err != nil { return err } - recipient, err := protocol.LoadRecipientPrivate(recipientPath, passphrase) + manifest, err := protocol.NewTeamKey(formation, githubID, public, encryption.Text) if err != nil { return err } - value, err := stream.OpenFile(input, output, binding, signer, recipient) + result, err := protocol.WriteArtifact(output, manifest, nil, private) if err != nil { return err } - return emit(command, value) + return emit(command, result) }} - command.Flags().StringVar(&input, "input", "", "encrypted input") - command.Flags().StringVar(&output, "output", "", "verified plaintext output") - command.Flags().StringVar(&context, "context", "", "stream binding JSON") - command.Flags().StringVar(&signerPath, "ver-public-key", "", "signing public key") - command.Flags().StringVar(&recipientPath, "dec-private-key", "", "encrypted recipient key") - command.Flags().StringVar(&passphraseFile, "passphrase-file", "", "recipient key passphrase file") - require(command, "input", "output", "context", "ver-public-key", "dec-private-key") + command.Flags().StringVar(&formationPath, "formation", "", "formation tar") + command.Flags().StringVar(&githubID, "github-id", "", "numeric GitHub account ID") + command.Flags().StringVar(&signingPath, "sig-priv-key", "", "Ed25519 private key") + command.Flags().StringVar(&encryptionPath, "enc-pub-key", "", "age recipient public key") + command.Flags().StringVar(&output, "out", "", "key tar output") + require(command, "formation", "github-id", "sig-priv-key", "enc-pub-key", "out") return command } -func identityCommand() *cobra.Command { - root := &cobra.Command{Use: "identity", Short: "register and verify participant identities"} - var eventPath, actorID, registrationID, signingPath, recipientPath, passphraseFile, output string - var keyEpoch int - register := &cobra.Command{Use: "register", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { - binding, err := protocol.ReadEventBinding(eventPath) - if err != nil { - return err - } - passphrase, err := readPassphrase(passphraseFile) - if err != nil { - return err - } - key, err := protocol.LoadSigningPrivate(signingPath, passphrase) - if err != nil { - return err - } - recipient, err := protocol.LoadRecipientPublic(recipientPath) +func submissionCommand() *cobra.Command { + root := &cobra.Command{Use: "submission", Short: "prepare participant submissions"} + var eventID, githubID, teamID, signingPath, output string + var inputs []string + prepare := &cobra.Command{Use: "prepare", Short: "sign and tar a submission", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + private, err := protocol.LoadSigningPrivate(signingPath) if err != nil { return err } - value, err := protocol.RegisterIdentity(binding, actorID, keyEpoch, registrationID, key, recipient, now()) + public := publicPEM(private) + manifest, err := protocol.NewSubmission(eventID, githubID, teamID, public) if err != nil { return err } - if err := protocol.WriteJSON(output, value); err != nil { - return err - } - return emit(command, map[string]string{"output": output, "actor_id": value.ActorID, "registration_id": value.RegistrationID}) - }} - register.Flags().StringVar(&eventPath, "event", "", "public event binding JSON") - register.Flags().StringVar(&actorID, "actor-id", "", "numeric GitHub actor ID") - register.Flags().IntVar(&keyEpoch, "key-epoch", 1, "identity key epoch") - register.Flags().StringVar(®istrationID, "registration-id", "", "UUIDv4 registration ID (generated if omitted)") - register.Flags().StringVar(&signingPath, "sig-private-key", "", "encrypted signing key") - register.Flags().StringVar(&recipientPath, "recipient-public-key", "", "age recipient public key") - register.Flags().StringVar(&passphraseFile, "passphrase-file", "", "signing key passphrase file") - register.Flags().StringVar(&output, "output", "", "registration JSON") - require(register, "event", "actor-id", "sig-private-key", "recipient-public-key", "output") - - var verifyEvent, verifyInput, verifyActor, verifySourceTime, verifyOutput string - verify := &cobra.Command{Use: "verify", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { - binding, err := protocol.ReadEventBinding(verifyEvent) - if err != nil { - return err - } - var document protocol.IdentityRegistration - if err := protocol.ReadJSON(verifyInput, &document); err != nil { - return err - } - record, err := protocol.VerifyIdentity(document, binding, verifyActor, verifySourceTime) + result, err := protocol.WriteArtifact(output, manifest, inputs, private) if err != nil { return err } - if err := protocol.WriteJSON(verifyOutput, record); err != nil { - return err - } - return emit(command, map[string]string{"output": verifyOutput, "actor_id": record.ActorID, "registration_id": record.RegistrationID}) + return emit(command, result) }} - verify.Flags().StringVar(&verifyEvent, "event", "", "public event binding JSON") - verify.Flags().StringVar(&verifyInput, "input", "", "registration JSON") - verify.Flags().StringVar(&verifyActor, "expect-actor-id", "", "trusted GitHub actor ID") - verify.Flags().StringVar(&verifySourceTime, "source-time", "", "trusted immutable source creation time") - verify.Flags().StringVar(&verifyOutput, "output", "", "verified identity record JSON") - require(verify, "event", "input", "expect-actor-id", "source-time", "output") - root.AddCommand(register, verify) + prepare.Flags().StringVar(&eventID, "event-id", "", "event repository slug") + prepare.Flags().StringVar(&githubID, "github-id", "", "numeric GitHub account ID") + prepare.Flags().StringVar(&teamID, "team-id", "", "team UUID") + prepare.Flags().StringArrayVar(&inputs, "input", nil, "submission input file (repeatable)") + prepare.Flags().StringVar(&signingPath, "sig-priv-key", "", "Ed25519 private key") + prepare.Flags().StringVar(&output, "out", "", "submission tar output") + require(prepare, "event-id", "github-id", "team-id", "sig-priv-key", "out") + root.AddCommand(prepare) return root } -func teamCommand() *cobra.Command { - root := &cobra.Command{Use: "team", Short: "prepare, consent to, and verify team proposals"} - var eventPath, registryPath, teamID, actorID, signingPath, passphraseFile, output string - var members []string - propose := &cobra.Command{Use: "propose", Aliases: []string{"register"}, Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { - binding, err := protocol.ReadEventBinding(eventPath) - if err != nil { - return err - } - registry, err := protocol.ReadRegistry(registryPath, binding) - if err != nil { - return err - } - passphrase, err := readPassphrase(passphraseFile) +func sigcryptCommand() *cobra.Command { + var signingPath, output string + var inputs, recipientPaths []string + command := &cobra.Command{Use: "sigcrypt", Short: "sign and encrypt tarred input files", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + private, err := protocol.LoadSigningPrivate(signingPath) if err != nil { return err } - key, err := protocol.LoadSigningPrivate(signingPath, passphrase) + recipients, err := loadRecipients(recipientPaths) if err != nil { return err } - proposal, err := protocol.ProposeTeam(binding, registry, teamID, actorID, members, key, now()) + result, err := protocol.SealFeedback(inputs, output, private, recipients) if err != nil { return err } - if err := protocol.WriteJSON(output, proposal); err != nil { - return err - } - return emit(command, map[string]string{"output": output, "team_id": proposal.TeamID}) + return emit(command, result) }} - propose.Flags().StringVar(&eventPath, "event", "", "public event binding JSON") - propose.Flags().StringVar(®istryPath, "registry", "", "protected active identity registry JSON") - propose.Flags().StringVar(&teamID, "team-id", "", "UUIDv4 team ID (generated if omitted)") - propose.Flags().StringVar(&actorID, "actor-id", "", "numeric proposer GitHub actor ID") - propose.Flags().StringArrayVar(&members, "member", nil, "member actor ID (repeatable, including proposer)") - propose.Flags().StringVar(&signingPath, "sig-private-key", "", "encrypted signing key") - propose.Flags().StringVar(&passphraseFile, "passphrase-file", "", "signing key passphrase file") - propose.Flags().StringVar(&output, "output", "", "team proposal JSON") - require(propose, "event", "registry", "actor-id", "sig-private-key", "output") + command.Flags().StringArrayVar(&inputs, "input", nil, "input file (repeatable)") + command.Flags().StringVar(&output, "out", "", "encrypted tar output") + command.Flags().StringVar(&signingPath, "sig-priv-key", "", "Ed25519 private key") + command.Flags().StringArrayVar(&recipientPaths, "enc-pub-key", nil, "age recipient public key (repeatable)") + require(command, "out", "sig-priv-key") + return command +} - var consentEvent, consentRegistry, proposalPath, consentActor, consentSigning, consentPassphrase, consentOutput string - consent := &cobra.Command{Use: "consent", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { - binding, err := protocol.ReadEventBinding(consentEvent) - if err != nil { - return err - } - registry, err := protocol.ReadRegistry(consentRegistry, binding) - if err != nil { - return err - } - var proposal protocol.TeamProposal - if err := protocol.ReadJSON(proposalPath, &proposal); err != nil { - return err - } - passphrase, err := readPassphrase(consentPassphrase) +func decverifyCommand() *cobra.Command { + var input, outputDirectory, signingPath, encryptionPath string + command := &cobra.Command{Use: "decverify", Short: "decrypt and verify encrypted feedback", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { + public, err := protocol.LoadSigningPublic(signingPath) if err != nil { return err } - key, err := protocol.LoadSigningPrivate(consentSigning, passphrase) + private, err := protocol.LoadEncryptionPrivate(encryptionPath) if err != nil { return err } - value, err := protocol.ConsentTeam(binding, registry, proposal, consentActor, key, now()) + result, err := protocol.OpenFeedback(input, outputDirectory, public, private) if err != nil { return err } - if err := protocol.WriteJSON(consentOutput, value); err != nil { - return err - } - return emit(command, map[string]string{"output": consentOutput, "actor_id": value.ActorID}) + return emit(command, result) }} - consent.Flags().StringVar(&consentEvent, "event", "", "public event binding JSON") - consent.Flags().StringVar(&consentRegistry, "registry", "", "protected active identity registry JSON") - consent.Flags().StringVar(&proposalPath, "proposal", "", "team proposal JSON") - consent.Flags().StringVar(&consentActor, "actor-id", "", "numeric member GitHub actor ID") - consent.Flags().StringVar(&consentSigning, "sig-private-key", "", "encrypted signing key") - consent.Flags().StringVar(&consentPassphrase, "passphrase-file", "", "signing key passphrase file") - consent.Flags().StringVar(&consentOutput, "output", "", "team consent JSON") - require(consent, "event", "registry", "proposal", "actor-id", "sig-private-key", "output") + command.Flags().StringVar(&input, "input", "", "encrypted feedback tar") + command.Flags().StringVar(&outputDirectory, "out-dir", "", "empty extraction directory") + command.Flags().StringVar(&signingPath, "sig-pub-key", "", "trusted Ed25519 public key") + command.Flags().StringVar(&encryptionPath, "enc-priv-key", "", "age private key") + require(command, "input", "out-dir", "sig-pub-key", "enc-priv-key") + return command +} - var verifyEvent, verifyRegistry, verifyProposal, proposalSourceTime, verifyOutput string - var consentPaths, consentSourceTimes []string - verify := &cobra.Command{Use: "verify", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { - binding, err := protocol.ReadEventBinding(verifyEvent) - if err != nil { - return err - } - registry, err := protocol.ReadRegistry(verifyRegistry, binding) - if err != nil { - return err - } - var proposal protocol.TeamProposal - if err := protocol.ReadJSON(verifyProposal, &proposal); err != nil { - return err - } - if len(consentPaths) == 0 { - return errors.New("at least one --consent is required") - } - consents := make([]protocol.TeamConsent, 0, len(consentPaths)) - for _, path := range consentPaths { - var consent protocol.TeamConsent - if err := protocol.ReadJSON(path, &consent); err != nil { - return err - } - consents = append(consents, consent) - } - sourceTimes, err := parseActorTimes(consentSourceTimes) - if err != nil { - return err - } - value, err := protocol.VerifyTeam(binding, registry, proposal, proposalSourceTime, consents, sourceTimes) - if err != nil { - return err - } - if err := protocol.WriteJSON(verifyOutput, value); err != nil { - return err - } - return emit(command, map[string]string{"output": verifyOutput, "team_id": value.TeamID, "proposal_sha256": value.ProposalSHA256}) - }} - verify.Flags().StringVar(&verifyEvent, "event", "", "public event binding JSON") - verify.Flags().StringVar(&verifyRegistry, "registry", "", "protected active identity registry JSON") - verify.Flags().StringVar(&verifyProposal, "proposal", "", "team proposal JSON") - verify.Flags().StringVar(&proposalSourceTime, "proposal-source-time", "", "trusted proposal pull-request creation time") - verify.Flags().StringArrayVar(&consentPaths, "consent", nil, "team consent JSON (repeatable)") - verify.Flags().StringArrayVar(&consentSourceTimes, "consent-source-time", nil, "actor_id=RFC3339 proof creation time (repeatable)") - verify.Flags().StringVar(&verifyOutput, "output", "", "verified team plan JSON") - require(verify, "event", "registry", "proposal", "proposal-source-time", "output") - root.AddCommand(propose, consent, verify) - return root +func publicPEM(private ed25519.PrivateKey) string { + return protocol.SigningPublicPEM(private.Public().(ed25519.PublicKey)) } -func submissionCommand() *cobra.Command { - root := &cobra.Command{Use: "submission", Short: "prepare and verify event-bound submissions"} - var eventPath, input, metadata, teamID, attemptID, actorID, signingPath, passphraseFile, output string - var keyEpoch int - prepare := &cobra.Command{Use: "prepare", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { - binding, err := protocol.ReadEventBinding(eventPath) - if err != nil { - return err - } - payload, err := protocol.ReadBytes(input) - if err != nil { - return err - } - var metadataBytes []byte - if metadata != "" { - metadataBytes, err = protocol.ReadBytes(metadata) - if err != nil { - return err - } - } - passphrase, err := readPassphrase(passphraseFile) - if err != nil { - return err - } - key, err := protocol.LoadSigningPrivate(signingPath, passphrase) +func loadRecipients(paths []string) ([]age.Recipient, error) { + if len(paths) == 0 { + return nil, errors.New("at least one --enc-pub-key is required") + } + loaded := make([]protocol.EncryptionPublic, 0, len(paths)) + for _, file := range paths { + value, err := protocol.LoadEncryptionPublic(file) if err != nil { - return err + return nil, err } - value, err := protocol.PrepareSubmission(binding, teamID, attemptID, actorID, keyEpoch, payload, metadataBytes, key, now()) - if err != nil { - return err - } - if err := protocol.WriteJSON(output, value); err != nil { - return err - } - return emit(command, map[string]string{"output": output, "attempt_id": value.AttemptID, "payload_sha256": value.PayloadSHA256}) - }} - prepare.Flags().StringVar(&eventPath, "event", "", "public event binding JSON") - prepare.Flags().StringVar(&input, "input", "", "submission byte stream") - prepare.Flags().StringVar(&metadata, "metadata", "", "optional metadata JSON") - prepare.Flags().StringVar(&teamID, "team-id", "", "active UUIDv4 team ID") - prepare.Flags().StringVar(&attemptID, "attempt-id", "", "UUIDv4 attempt ID") - prepare.Flags().StringVar(&actorID, "actor-id", "", "numeric submitter GitHub actor ID") - prepare.Flags().IntVar(&keyEpoch, "key-epoch", 1, "identity key epoch") - prepare.Flags().StringVar(&signingPath, "sig-private-key", "", "encrypted signing key") - prepare.Flags().StringVar(&passphraseFile, "passphrase-file", "", "signing key passphrase file") - prepare.Flags().StringVar(&output, "output", "", "submission request JSON") - require(prepare, "event", "input", "team-id", "attempt-id", "actor-id", "sig-private-key", "output") + loaded = append(loaded, value) + } + unique := lo.UniqBy(loaded, func(value protocol.EncryptionPublic) string { return value.Text }) + return lo.Map(unique, func(value protocol.EncryptionPublic, _ int) age.Recipient { return value.Recipient }), nil +} - var verifyEvent, verifyRegistry, requestPath, bundlePath, verifyMetadata, verifyActor, verifySourceTime, verifyOutput string - verify := &cobra.Command{Use: "verify", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { - binding, err := protocol.ReadEventBinding(verifyEvent) - if err != nil { - return err - } - registry, err := protocol.ReadRegistry(verifyRegistry, binding) - if err != nil { - return err - } - var document protocol.Submission - if err := protocol.ReadJSON(requestPath, &document); err != nil { - return err - } - bundle, err := protocol.ReadBytes(bundlePath) - if err != nil { - return err - } - var metadataBytes []byte - if verifyMetadata != "" { - metadataBytes, err = protocol.ReadBytes(verifyMetadata) - if err != nil { - return err - } - } - if err := protocol.VerifySubmission(document, binding, registry, verifyActor, verifySourceTime, bundle, metadataBytes); err != nil { - return err - } - if err := protocol.WriteJSON(verifyOutput, document); err != nil { - return err - } - return emit(command, map[string]string{"output": verifyOutput, "attempt_id": document.AttemptID, "team_id": document.TeamID}) - }} - verify.Flags().StringVar(&verifyEvent, "event", "", "public event binding JSON") - verify.Flags().StringVar(&verifyRegistry, "registry", "", "protected active identity registry JSON") - verify.Flags().StringVar(&requestPath, "request", "", "submission request JSON") - verify.Flags().StringVar(&bundlePath, "bundle", "", "exact submitted bundle") - verify.Flags().StringVar(&verifyMetadata, "metadata", "", "optional exact metadata") - verify.Flags().StringVar(&verifyActor, "expect-actor-id", "", "trusted GitHub actor ID") - verify.Flags().StringVar(&verifySourceTime, "source-time", "", "trusted immutable source creation time") - verify.Flags().StringVar(&verifyOutput, "output", "", "verified submission request JSON") - require(verify, "event", "registry", "request", "bundle", "expect-actor-id", "source-time", "output") - root.AddCommand(prepare, verify) - return root +func emit(command *cobra.Command, value any) error { + writeResponse(command.OutOrStdout(), response{OK: true, Command: strings.TrimSpace(strings.TrimPrefix(command.CommandPath(), "eventctl")), Result: value}) + return nil } -func parseActorTimes(values []string) (map[string]string, error) { - times := make(map[string]string, len(values)) - for _, value := range values { - actorID, timestamp, found := strings.Cut(value, "=") - if !found || actorID == "" || timestamp == "" || times[actorID] != "" { - return nil, errors.New("consent source time must be unique actor_id=RFC3339") - } - times[actorID] = timestamp - } - return times, nil +func writeResponse(writer io.Writer, value response) { + _, _ = fmt.Fprintln(writer, string(lo.Must(json.Marshal(value)))) } func require(command *cobra.Command, names ...string) { for _, name := range names { - _ = command.MarkFlagRequired(name) - } -} - -func now() time.Time { return time.Now().UTC() } - -func readPassphrase(path string) (string, error) { - if path == "" { - return "", errors.New("--passphrase-file is required") - } - data, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("read passphrase: %w", err) + lo.Must0(command.MarkFlagRequired(name)) } - passphrase := strings.TrimSpace(string(data)) - if passphrase == "" { - return "", errors.New("passphrase file is empty") - } - return passphrase, nil -} - -func emit(command *cobra.Command, value any) error { - return writeResponse(command.OutOrStdout(), response{OK: true, Command: strings.TrimPrefix(command.CommandPath(), "eventctl "), Result: value}) -} - -func writeResponse(writer io.Writer, value response) error { - encoder := json.NewEncoder(writer) - encoder.SetEscapeHTML(false) - return encoder.Encode(value) } diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index 63e3792..0000000 --- a/docs/architecture.md +++ /dev/null @@ -1,104 +0,0 @@ -# eventctl architecture - -`eventctl` is a local protocol tool, not an event platform. The repository -controls GitHub transport, review, and state promotion; `eventctl` controls -strict documents, signatures, age encryption, and verification. - -```text -participant fork / organizer workflow - │ files, trusted actor ID, immutable source time - ▼ - Cobra command layer - │ - ┌────────┴────────┐ - ▼ ▼ - protocol v2 stream container - event + registry signed header + age payload -``` - -## Trust model - -The repository has two relevant branches: - -```text -main public event binding, workflows, request history -registry protected authoritative event state -``` - -`event/binding.json` from trusted `main` defines policy and derives an event -reference. A participant-signed document carries that reference. The reviewed -`registry/state.json` from `registry` contains the active identities, teams, -and attempts. Both are command inputs; neither is fetched over the network by -the CLI. - -There is deliberately no GitHub App, app private key, organizer PEM, GitHub -token, or GitHub API client in the runtime. GitHub verifies who opened a pull -request; the trusted workflow passes that numeric actor ID and GitHub-created -timestamp to `eventctl`. The organizer promotes the resulting verified record -through a normal reviewed registry PR. - -## Protocol documents - -All JSON documents are bounded, reject duplicate keys and unknown fields, and -use concrete structures for stable signed bytes. Signatures are Ed25519 over: - -```text -eventctl:eventctl/v2:\0 -``` - -The protocol has four main public document types: - -- `identity-registration`: binds a GitHub actor ID to an Ed25519 public key, - age recipient public key, key epoch, event reference, and short request - window. -- `team-proposal` and `team-consent`: bind the exact sorted member set and - their registry-pinned key epochs. All members must consent separately. -- `submission`: binds a team, actor, attempt, payload/metadata digests, and - request window. -- `stream-binding`: binds an encrypted result artifact to the event, team, - attempt, purpose, and trusted artifact ID. - -The protected registry is intentionally one normalized document, rather than -separate identity, membership, and replay indexes. It validates: - -- event reference and lifecycle phase; -- sorted active identities and their public keys; -- sorted active teams, with no member active in two teams; -- sorted attempts whose submitter belongs to the recorded team. - -`team verify` rejects a team ID or member already active in that registry. -`submission verify` requires `submissions_open`, active membership with the -same key epoch, and an unused attempt within the binding's quotas. The command -does not write state—the reviewed registry PR is the durable state transition. - -## Key custody and stream crypto - -`key-gen` creates separate participant key material: - -```text -Ed25519 private key signs participant documents -age hybrid private key decrypts artifacts addressed to that participant -``` - -Both private files are age-scrypt encrypted with one local passphrase. The -separate primitives are intentional: an Ed25519 key cannot be safely reused -as an age encryption key. - -The stream container is: - -```text -8-byte magic | 4-byte big-endian header length | JSON header | age ciphertext -``` - -The signed header covers the public stream binding, signer, sorted recipient -key IDs, payload size, and SHA-256 digest. The payload is age-encrypted to all -recipient public keys. `decverify` verifies the header before accepting a -decrypted payload and creates its output exclusively. - -## Testing boundary - -The only test suite is `tests/e2e`. It builds the real CLI with -`-cover -coverpkg=./...`, invokes it as a subprocess, and drives state through -event binding, identity, team, submission, stream, malformed-file, replay, and -quota scenarios. Every subprocess inherits `GOCOVERDIR`; `mise run test` -merges the counters and requires 100.0% statement coverage. diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go index 83e7ee6..43f31d5 100644 --- a/internal/buildinfo/buildinfo.go +++ b/internal/buildinfo/buildinfo.go @@ -1,11 +1,11 @@ package buildinfo const ( - Protocol = "eventctl/v2" + Protocol = "eventctl/v3" ) var ( - Version = "0.2.0-dev" + Version = "0.3.0-dev" Commit = "dev" ) diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go deleted file mode 100644 index aaa735d..0000000 --- a/internal/protocol/protocol.go +++ /dev/null @@ -1,1163 +0,0 @@ -package protocol - -import ( - "bytes" - "crypto/ed25519" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "regexp" - "sort" - "strconv" - "strings" - "time" - - "filippo.io/age" - "github.com/samber/lo" -) - -const ( - Protocol = "eventctl/v2" - SchemaVersion = 2 - MaxBytes = 64 << 20 -) - -var ( - eventIDPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{2,63}$`) - decimalPattern = regexp.MustCompile(`^[1-9][0-9]{0,19}$`) - digestPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) - uuidV4Pattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) -) - -type TTLSeconds struct { - Registration uint64 `json:"registration"` - Team uint64 `json:"team"` - Submission uint64 `json:"submission"` -} - -type Limits struct { - TeamMinimum uint64 `json:"team_min"` - TeamMaximum uint64 `json:"team_max"` - AttemptsPerTeam uint64 `json:"attempts_per_team"` - AttemptsTotal uint64 `json:"attempts_total"` -} - -// EventBinding is public event policy from trusted main. The repository and -// its reviewed registry are the trust boundary; no app or organizer secret is -// part of normal eventctl operation. -type EventBinding struct { - Version int `json:"v"` - Kind string `json:"kind"` - Protocol string `json:"protocol"` - EventID string `json:"event_id"` - EventEpoch int `json:"event_epoch"` - RepositoryID string `json:"repository_id"` - ValidFrom string `json:"valid_from"` - ValidUntil string `json:"valid_until"` - TermsSHA256 string `json:"terms_sha256"` - TTLSeconds TTLSeconds `json:"ttl_seconds"` - Limits Limits `json:"limits"` -} - -type EventReference struct { - EventID string `json:"event_id"` - EventEpoch int `json:"event_epoch"` - RepositoryID string `json:"repository_id"` - BindingSHA256 string `json:"binding_sha256"` -} - -func (binding EventBinding) Validate() error { - if binding.Version != SchemaVersion || binding.Kind != "event-binding" || binding.Protocol != Protocol { - return errors.New("event binding protocol discriminator is invalid") - } - if !eventIDPattern.MatchString(binding.EventID) || binding.EventEpoch < 1 || !decimalPattern.MatchString(binding.RepositoryID) || !digestPattern.MatchString(binding.TermsSHA256) { - return errors.New("event binding identity is invalid") - } - validFrom, err := parseTimestamp(binding.ValidFrom) - if err != nil { - return fmt.Errorf("event binding valid_from: %w", err) - } - validUntil, err := parseTimestamp(binding.ValidUntil) - if err != nil || !validUntil.After(validFrom) { - return errors.New("event binding validity window is invalid") - } - if binding.TTLSeconds.Registration == 0 || binding.TTLSeconds.Team == 0 || binding.TTLSeconds.Submission == 0 { - return errors.New("event binding request TTL is invalid") - } - if binding.Limits.TeamMinimum < 1 || binding.Limits.TeamMaximum < binding.Limits.TeamMinimum || binding.Limits.TeamMaximum > 64 || binding.Limits.AttemptsPerTeam < 1 || binding.Limits.AttemptsTotal < binding.Limits.AttemptsPerTeam { - return errors.New("event binding limits are invalid") - } - return nil -} - -func (binding EventBinding) Reference() EventReference { - return EventReference{ - EventID: binding.EventID, - EventEpoch: binding.EventEpoch, - RepositoryID: binding.RepositoryID, - BindingSHA256: DigestJSON(binding), - } -} - -func (reference EventReference) Validate() error { - if !eventIDPattern.MatchString(reference.EventID) || reference.EventEpoch < 1 || !decimalPattern.MatchString(reference.RepositoryID) || !digestPattern.MatchString(reference.BindingSHA256) { - return errors.New("event reference is invalid") - } - return nil -} - -func (reference EventReference) Match(binding EventBinding) error { - if err := reference.Validate(); err != nil { - return err - } - if reference != binding.Reference() { - return errors.New("event reference does not match binding") - } - return nil -} - -func ReadEventBinding(path string) (EventBinding, error) { - var binding EventBinding - if err := ReadJSON(path, &binding); err != nil { - return binding, fmt.Errorf("read event binding: %w", err) - } - if err := binding.Validate(); err != nil { - return binding, err - } - return binding, nil -} - -type SigningPublic struct { - Algorithm string `json:"alg"` - KeyID string `json:"kid"` - PublicKey string `json:"public"` -} - -type signingPrivate struct { - Kind string `json:"kind"` - Algorithm string `json:"alg"` - KeyID string `json:"kid"` - PrivateKey string `json:"private"` -} - -type RecipientPublic struct { - Algorithm string `json:"alg"` - KeyID string `json:"kid"` - PublicKey string `json:"recipient"` - Recipient age.Recipient `json:"-"` -} - -type recipientPrivate struct { - Kind string `json:"kind"` - Algorithm string `json:"alg"` - KeyID string `json:"kid"` - Identity string `json:"identity"` -} - -type KeyGenerationResult struct { - SigningPrivate string `json:"signing_private_key"` - SigningPublic string `json:"signing_public_key"` - RecipientPrivate string `json:"recipient_private_key"` - RecipientPublic string `json:"recipient_public_key"` - SigningKeyID string `json:"signing_key_id"` - RecipientKeyID string `json:"recipient_key_id"` -} - -type SigningKey struct { - Public SigningPublic - Private ed25519.PrivateKey -} - -type RecipientKey struct { - Public RecipientPublic - Identity *age.HybridIdentity -} - -func GenerateKeyDirectory(directory, passphrase string) (KeyGenerationResult, error) { - if err := os.MkdirAll(directory, 0o700); err != nil { - return KeyGenerationResult{}, fmt.Errorf("create key directory: %w", err) - } - // Go 1.26 guarantees crypto/rand.Read either fills the buffer or crashes; - // this CLI's minimum Go version makes an ignored error impossible here. - seed := make([]byte, ed25519.SeedSize) - _, _ = rand.Read(seed) - private := ed25519.NewKeyFromSeed(seed) - public := private.Public().(ed25519.PublicKey) - signingID := fingerprint(public) - signingPublic := SigningPublic{Algorithm: "Ed25519", KeyID: signingID, PublicKey: base64.RawURLEncoding.EncodeToString(public)} - signingPrivate := signingPrivate{Kind: "signing-private", Algorithm: "Ed25519", KeyID: signingID, PrivateKey: base64.RawURLEncoding.EncodeToString(private)} - // age hybrid generation uses crypto/rand, whose failure exits this Go - // runtime before an error can be returned. - identity := lo.Must(age.GenerateHybridIdentity()) - recipientID := fingerprint([]byte(identity.Recipient().String())) - recipientPublic := RecipientPublic{Algorithm: "age-hybrid-mlkem768-x25519", KeyID: recipientID, PublicKey: identity.Recipient().String(), Recipient: identity.Recipient()} - recipientPrivate := recipientPrivate{Kind: "recipient-private", Algorithm: recipientPublic.Algorithm, KeyID: recipientID, Identity: identity.String()} - signingData := canonicalJSON(signingPrivate) - recipientData := canonicalJSON(recipientPrivate) - signingCiphertext := encryptWithPass(signingData, passphrase) - recipientCiphertext := encryptWithPass(recipientData, passphrase) - signingPublicData := canonicalJSON(signingPublic) - recipientPublicData := canonicalJSON(recipientPublic) - signingPrivatePath := filepath.Join(directory, "signing.private.age") - signingPublicPath := filepath.Join(directory, "signing.public.json") - recipientPrivatePath := filepath.Join(directory, "recipient.private.age") - recipientPublicPath := filepath.Join(directory, "recipient.public.json") - for _, file := range []struct { - path string - data []byte - mode os.FileMode - }{ - {signingPrivatePath, signingCiphertext, 0o600}, - {signingPublicPath, signingPublicData, 0o644}, - {recipientPrivatePath, recipientCiphertext, 0o600}, - {recipientPublicPath, recipientPublicData, 0o644}, - } { - if err := writeExclusive(file.path, file.data, file.mode); err != nil { - return KeyGenerationResult{}, fmt.Errorf("write key file %s: %w", file.path, err) - } - } - return KeyGenerationResult{SigningPrivate: signingPrivatePath, SigningPublic: signingPublicPath, RecipientPrivate: recipientPrivatePath, RecipientPublic: recipientPublicPath, SigningKeyID: signingID, RecipientKeyID: recipientID}, nil -} - -func LoadSigningPrivate(path, passphrase string) (SigningKey, error) { - data, err := readFile(path) - if err != nil { - return SigningKey{}, fmt.Errorf("read signing key: %w", err) - } - plain, err := decryptWithPass(data, passphrase) - if err != nil { - return SigningKey{}, fmt.Errorf("decrypt signing key: %w", err) - } - var document signingPrivate - if err := decodeJSON(plain, &document); err != nil { - return SigningKey{}, fmt.Errorf("decode signing key: %w", err) - } - key, err := base64.RawURLEncoding.DecodeString(document.PrivateKey) - if err != nil || len(key) != ed25519.PrivateKeySize { - return SigningKey{}, errors.New("invalid signing private key") - } - private := ed25519.PrivateKey(key) - public := private.Public().(ed25519.PublicKey) - if document.Kind != "signing-private" || document.Algorithm != "Ed25519" || document.KeyID != fingerprint(public) { - return SigningKey{}, errors.New("signing key metadata does not match") - } - return SigningKey{Public: SigningPublic{Algorithm: document.Algorithm, KeyID: document.KeyID, PublicKey: base64.RawURLEncoding.EncodeToString(public)}, Private: private}, nil -} - -func LoadSigningPublic(path string) (SigningPublic, error) { - var document SigningPublic - if err := ReadJSON(path, &document); err != nil { - return document, fmt.Errorf("read signing public key: %w", err) - } - if _, err := decodeSigningPublic(document); err != nil { - return document, err - } - return document, nil -} - -func LoadRecipientPublic(path string) (RecipientPublic, error) { - var document RecipientPublic - if err := ReadJSON(path, &document); err != nil { - return document, fmt.Errorf("read recipient public key: %w", err) - } - if err := validateRecipientPublic(&document); err != nil { - return document, err - } - return document, nil -} - -func LoadRecipientPrivate(path, passphrase string) (RecipientKey, error) { - data, err := readFile(path) - if err != nil { - return RecipientKey{}, fmt.Errorf("read recipient key: %w", err) - } - plain, err := decryptWithPass(data, passphrase) - if err != nil { - return RecipientKey{}, fmt.Errorf("decrypt recipient key: %w", err) - } - var document recipientPrivate - if err := decodeJSON(plain, &document); err != nil { - return RecipientKey{}, fmt.Errorf("decode recipient key: %w", err) - } - if document.Kind != "recipient-private" || document.Algorithm != "age-hybrid-mlkem768-x25519" { - return RecipientKey{}, errors.New("invalid recipient private key metadata") - } - identity, err := age.ParseHybridIdentity(document.Identity) - if err != nil { - return RecipientKey{}, fmt.Errorf("parse recipient private key: %w", err) - } - public := identity.Recipient().String() - if document.KeyID != fingerprint([]byte(public)) { - return RecipientKey{}, errors.New("recipient private key fingerprint mismatch") - } - return RecipientKey{Public: RecipientPublic{Algorithm: document.Algorithm, KeyID: document.KeyID, PublicKey: public, Recipient: identity.Recipient()}, Identity: identity}, nil -} - -type Signature struct { - Algorithm string `json:"alg"` - KeyID string `json:"kid"` - Value string `json:"value"` -} - -func Sign(domain string, value any, key SigningKey) Signature { - message := signedMessage(domain, value) - return Signature{Algorithm: "Ed25519", KeyID: key.Public.KeyID, Value: base64.RawURLEncoding.EncodeToString(ed25519.Sign(key.Private, message))} -} - -func Verify(domain string, value any, signature Signature, public SigningPublic) error { - key, err := decodeSigningPublic(public) - if err != nil { - return err - } - if signature.Algorithm != "Ed25519" || signature.KeyID != public.KeyID { - return errors.New("signature metadata does not match signer") - } - signed, err := base64.RawURLEncoding.DecodeString(signature.Value) - if err != nil || len(signed) != ed25519.SignatureSize { - return errors.New("invalid signature encoding") - } - if !ed25519.Verify(key, signedMessage(domain, value), signed) { - return errors.New("signature verification failed") - } - return nil -} - -type IdentityRegistration struct { - Version int `json:"v"` - Kind string `json:"kind"` - Event EventReference `json:"event"` - RegistrationID string `json:"registration_id"` - ActorID string `json:"actor_id"` - KeyEpoch int `json:"key_epoch"` - SigningKey SigningPublic `json:"signing_key"` - RecipientKey RecipientPublic `json:"recipient_key"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` - Signature Signature `json:"signature"` -} - -type identityUnsigned struct { - Version int `json:"v"` - Kind string `json:"kind"` - Event EventReference `json:"event"` - RegistrationID string `json:"registration_id"` - ActorID string `json:"actor_id"` - KeyEpoch int `json:"key_epoch"` - SigningKey SigningPublic `json:"signing_key"` - RecipientKey RecipientPublic `json:"recipient_key"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` -} - -func (document IdentityRegistration) unsigned() identityUnsigned { - return identityUnsigned{Version: document.Version, Kind: document.Kind, Event: document.Event, RegistrationID: document.RegistrationID, ActorID: document.ActorID, KeyEpoch: document.KeyEpoch, SigningKey: document.SigningKey, RecipientKey: document.RecipientKey, IssuedAt: document.IssuedAt, ExpiresAt: document.ExpiresAt} -} - -func RegisterIdentity(binding EventBinding, actorID string, keyEpoch int, registrationID string, signing SigningKey, recipient RecipientPublic, now time.Time) (IdentityRegistration, error) { - if !validActorID(actorID) || keyEpoch < 1 { - return IdentityRegistration{}, errors.New("identity registration actor or key epoch is invalid") - } - if registrationID == "" { - registrationID = newUUIDv4() - } - if !uuidV4Pattern.MatchString(registrationID) { - return IdentityRegistration{}, errors.New("identity registration ID is invalid") - } - issuedAt, expiresAt, err := issueWindow(binding, binding.TTLSeconds.Registration, now) - if err != nil { - return IdentityRegistration{}, err - } - document := IdentityRegistration{Version: SchemaVersion, Kind: "identity-registration", Event: binding.Reference(), RegistrationID: registrationID, ActorID: actorID, KeyEpoch: keyEpoch, SigningKey: signing.Public, RecipientKey: recipient, IssuedAt: issuedAt, ExpiresAt: expiresAt} - document.Signature = Sign("identity.register", document.unsigned(), signing) - return document, nil -} - -type IdentityRecord struct { - ActorID string `json:"actor_id"` - RegistrationID string `json:"registration_id"` - RegistrationSHA256 string `json:"registration_sha256"` - KeyEpoch int `json:"key_epoch"` - SigningKey SigningPublic `json:"signing_key"` - RecipientKey RecipientPublic `json:"recipient_key"` -} - -func VerifyIdentity(document IdentityRegistration, binding EventBinding, expectedActorID, sourceTime string) (IdentityRecord, error) { - if document.Version != SchemaVersion || document.Kind != "identity-registration" || !validActorID(document.ActorID) || document.ActorID != expectedActorID || document.KeyEpoch < 1 || !uuidV4Pattern.MatchString(document.RegistrationID) { - return IdentityRecord{}, errors.New("identity registration identity is invalid") - } - if err := document.Event.Match(binding); err != nil { - return IdentityRecord{}, err - } - at, err := parseTimestamp(sourceTime) - if err != nil { - return IdentityRecord{}, fmt.Errorf("identity registration source time: %w", err) - } - if err := validateDocumentWindow(document.IssuedAt, document.ExpiresAt, binding, binding.TTLSeconds.Registration, at); err != nil { - return IdentityRecord{}, err - } - if err := validateRecipientPublic(&document.RecipientKey); err != nil { - return IdentityRecord{}, err - } - if err := Verify("identity.register", document.unsigned(), document.Signature, document.SigningKey); err != nil { - return IdentityRecord{}, err - } - return IdentityRecord{ActorID: document.ActorID, RegistrationID: document.RegistrationID, RegistrationSHA256: DigestJSON(document), KeyEpoch: document.KeyEpoch, SigningKey: document.SigningKey, RecipientKey: document.RecipientKey}, nil -} - -type Registry struct { - Version int `json:"v"` - Kind string `json:"kind"` - Event EventReference `json:"event"` - Revision uint64 `json:"revision"` - Phase string `json:"phase"` - Enabled bool `json:"enabled"` - DisabledReason string `json:"disabled_reason"` - Identities []IdentityRecord `json:"identities"` - Teams []TeamRecord `json:"teams"` - Attempts []AttemptRecord `json:"attempts"` -} - -type TeamRecord struct { - TeamID string `json:"team_id"` - ProposalSHA256 string `json:"proposal_sha256"` - Members []TeamMember `json:"members"` -} - -type AttemptRecord struct { - AttemptID string `json:"attempt_id"` - TeamID string `json:"team_id"` - ActorID string `json:"actor_id"` - SubmissionSHA256 string `json:"submission_sha256"` - PayloadSHA256 string `json:"payload_sha256"` -} - -func ReadRegistry(path string, binding EventBinding) (Registry, error) { - var registry Registry - if err := ReadJSON(path, ®istry); err != nil { - return registry, fmt.Errorf("read registry: %w", err) - } - if err := registry.Validate(binding); err != nil { - return registry, err - } - return registry, nil -} - -func (registry Registry) Validate(binding EventBinding) error { - if registry.Version != SchemaVersion || registry.Kind != "event-registry" { - return errors.New("registry protocol discriminator is invalid") - } - if err := registry.Event.Match(binding); err != nil { - return err - } - if !lo.Contains([]string{"draft", "registration_open", "formation_open", "submissions_open", "closed"}, registry.Phase) { - return errors.New("registry phase is invalid") - } - if registry.Enabled && registry.DisabledReason != "" { - return errors.New("enabled registry cannot have a disabled reason") - } - if !registry.Enabled && registry.DisabledReason == "" { - return errors.New("disabled registry requires a disabled reason") - } - for index, identity := range registry.Identities { - if !validActorID(identity.ActorID) || !uuidV4Pattern.MatchString(identity.RegistrationID) || !digestPattern.MatchString(identity.RegistrationSHA256) || identity.KeyEpoch < 1 { - return errors.New("registry identity is invalid") - } - if _, err := decodeSigningPublic(identity.SigningKey); err != nil { - return err - } - if err := validateRecipientPublic(&identity.RecipientKey); err != nil { - return err - } - if index > 0 && compareActorIDs(registry.Identities[index-1].ActorID, identity.ActorID) >= 0 { - return errors.New("registry identities must be strictly sorted") - } - } - activeMembers := make(map[string]bool) - for index, team := range registry.Teams { - if !uuidV4Pattern.MatchString(team.TeamID) || !digestPattern.MatchString(team.ProposalSHA256) || uint64(len(team.Members)) < binding.Limits.TeamMinimum || uint64(len(team.Members)) > binding.Limits.TeamMaximum { - return errors.New("registry team is invalid") - } - if index > 0 && registry.Teams[index-1].TeamID >= team.TeamID { - return errors.New("registry teams must be strictly sorted") - } - for memberIndex, member := range team.Members { - identity, err := registry.Lookup(member.ActorID) - if err != nil || member != memberFromIdentity(identity) || activeMembers[member.ActorID] { - return errors.New("registry team member does not match an available identity") - } - if memberIndex > 0 && compareActorIDs(team.Members[memberIndex-1].ActorID, member.ActorID) >= 0 { - return errors.New("registry team members must be strictly sorted") - } - activeMembers[member.ActorID] = true - } - } - for index, attempt := range registry.Attempts { - if !uuidV4Pattern.MatchString(attempt.AttemptID) || !uuidV4Pattern.MatchString(attempt.TeamID) || !validActorID(attempt.ActorID) || !digestPattern.MatchString(attempt.SubmissionSHA256) || !digestPattern.MatchString(attempt.PayloadSHA256) { - return errors.New("registry attempt is invalid") - } - if index > 0 && registry.Attempts[index-1].AttemptID >= attempt.AttemptID { - return errors.New("registry attempts must be strictly sorted") - } - team, err := registry.LookupTeam(attempt.TeamID) - if err != nil || !lo.ContainsBy(team.Members, func(member TeamMember) bool { return member.ActorID == attempt.ActorID }) { - return errors.New("registry attempt submitter is not an active team member") - } - } - return nil -} - -func (registry Registry) Lookup(actorID string) (IdentityRecord, error) { - for _, identity := range registry.Identities { - if identity.ActorID == actorID { - return identity, nil - } - } - return IdentityRecord{}, errors.New("actor has no active registered identity") -} - -func (registry Registry) LookupTeam(teamID string) (TeamRecord, error) { - for _, team := range registry.Teams { - if team.TeamID == teamID { - return team, nil - } - } - return TeamRecord{}, errors.New("team is not active") -} - -func (registry Registry) RequirePhase(phase string) error { - if !registry.Enabled || registry.Phase != phase { - return errors.New("registry is not open for this operation") - } - return nil -} - -func (registry Registry) teamAvailable(teamID string, members []TeamMember) error { - if _, err := registry.LookupTeam(teamID); err == nil { - return errors.New("team ID is already active") - } - for _, member := range members { - for _, team := range registry.Teams { - if lo.ContainsBy(team.Members, func(active TeamMember) bool { return active.ActorID == member.ActorID }) { - return errors.New("team member already belongs to an active team") - } - } - } - return nil -} - -func (registry Registry) hasAttempt(attemptID string) bool { - return lo.ContainsBy(registry.Attempts, func(attempt AttemptRecord) bool { return attempt.AttemptID == attemptID }) -} - -func (registry Registry) attemptsForTeam(teamID string) uint64 { - return uint64(len(lo.Filter(registry.Attempts, func(attempt AttemptRecord, _ int) bool { return attempt.TeamID == teamID }))) -} - -type TeamMember struct { - ActorID string `json:"actor_id"` - KeyEpoch int `json:"key_epoch"` - SigningKeyID string `json:"signing_kid"` - RecipientKeyID string `json:"recipient_kid"` -} - -type TeamProposal struct { - Version int `json:"v"` - Kind string `json:"kind"` - Event EventReference `json:"event"` - TeamID string `json:"team_id"` - Proposer TeamMember `json:"proposer"` - Members []TeamMember `json:"members"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` - Signature Signature `json:"signature"` -} - -type teamProposalUnsigned struct { - Version int `json:"v"` - Kind string `json:"kind"` - Event EventReference `json:"event"` - TeamID string `json:"team_id"` - Proposer TeamMember `json:"proposer"` - Members []TeamMember `json:"members"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` -} - -func (proposal TeamProposal) unsigned() teamProposalUnsigned { - return teamProposalUnsigned{Version: proposal.Version, Kind: proposal.Kind, Event: proposal.Event, TeamID: proposal.TeamID, Proposer: proposal.Proposer, Members: proposal.Members, IssuedAt: proposal.IssuedAt, ExpiresAt: proposal.ExpiresAt} -} - -type TeamConsent struct { - Version int `json:"v"` - Kind string `json:"kind"` - Event EventReference `json:"event"` - TeamID string `json:"team_id"` - ProposalSHA256 string `json:"proposal_sha256"` - ActorID string `json:"actor_id"` - KeyEpoch int `json:"key_epoch"` - SigningKeyID string `json:"signing_kid"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` - Signature Signature `json:"signature"` -} - -type teamConsentUnsigned struct { - Version int `json:"v"` - Kind string `json:"kind"` - Event EventReference `json:"event"` - TeamID string `json:"team_id"` - ProposalSHA256 string `json:"proposal_sha256"` - ActorID string `json:"actor_id"` - KeyEpoch int `json:"key_epoch"` - SigningKeyID string `json:"signing_kid"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` -} - -func (consent TeamConsent) unsigned() teamConsentUnsigned { - return teamConsentUnsigned{Version: consent.Version, Kind: consent.Kind, Event: consent.Event, TeamID: consent.TeamID, ProposalSHA256: consent.ProposalSHA256, ActorID: consent.ActorID, KeyEpoch: consent.KeyEpoch, SigningKeyID: consent.SigningKeyID, IssuedAt: consent.IssuedAt, ExpiresAt: consent.ExpiresAt} -} - -type TeamVerification struct { - TeamID string `json:"team_id"` - ProposalSHA256 string `json:"proposal_sha256"` - Members []TeamMember `json:"members"` - Verified bool `json:"verified"` -} - -func ProposeTeam(binding EventBinding, registry Registry, teamID, proposerActorID string, memberActorIDs []string, signing SigningKey, now time.Time) (TeamProposal, error) { - if err := registry.RequirePhase("formation_open"); err != nil { - return TeamProposal{}, err - } - if teamID == "" { - teamID = newUUIDv4() - } - if !uuidV4Pattern.MatchString(teamID) || !validActorID(proposerActorID) { - return TeamProposal{}, errors.New("team proposal ID or proposer is invalid") - } - memberActorIDs = normalizeActorIDs(memberActorIDs) - if uint64(len(memberActorIDs)) < binding.Limits.TeamMinimum || uint64(len(memberActorIDs)) > binding.Limits.TeamMaximum || !lo.Contains(memberActorIDs, proposerActorID) { - return TeamProposal{}, errors.New("team proposal members are invalid") - } - proposer, err := registry.Lookup(proposerActorID) - if err != nil { - return TeamProposal{}, err - } - if signing.Public.KeyID != proposer.SigningKey.KeyID { - return TeamProposal{}, errors.New("team proposer key is not the active registered key") - } - members := make([]TeamMember, 0, len(memberActorIDs)) - for _, actorID := range memberActorIDs { - identity, lookupErr := registry.Lookup(actorID) - if lookupErr != nil { - return TeamProposal{}, lookupErr - } - members = append(members, memberFromIdentity(identity)) - } - if err := registry.teamAvailable(teamID, members); err != nil { - return TeamProposal{}, err - } - issuedAt, expiresAt, err := issueWindow(binding, binding.TTLSeconds.Team, now) - if err != nil { - return TeamProposal{}, err - } - document := TeamProposal{Version: SchemaVersion, Kind: "team-proposal", Event: binding.Reference(), TeamID: teamID, Proposer: memberFromIdentity(proposer), Members: members, IssuedAt: issuedAt, ExpiresAt: expiresAt} - document.Signature = Sign("team.propose", document.unsigned(), signing) - return document, nil -} - -func ConsentTeam(binding EventBinding, registry Registry, proposal TeamProposal, actorID string, signing SigningKey, now time.Time) (TeamConsent, error) { - if err := registry.RequirePhase("formation_open"); err != nil { - return TeamConsent{}, err - } - issued := now.UTC().Truncate(time.Second) - if err := verifyProposal(proposal, binding, registry, issued); err != nil { - return TeamConsent{}, err - } - if err := registry.teamAvailable(proposal.TeamID, proposal.Members); err != nil { - return TeamConsent{}, err - } - identity, err := registry.Lookup(actorID) - if err != nil { - return TeamConsent{}, err - } - if !lo.ContainsBy(proposal.Members, func(member TeamMember) bool { return member.ActorID == actorID }) || identity.SigningKey.KeyID != signing.Public.KeyID { - return TeamConsent{}, errors.New("team consent actor is not an active proposed member") - } - document := TeamConsent{Version: SchemaVersion, Kind: "team-consent", Event: binding.Reference(), TeamID: proposal.TeamID, ProposalSHA256: DigestJSON(proposal), ActorID: actorID, KeyEpoch: identity.KeyEpoch, SigningKeyID: identity.SigningKey.KeyID, IssuedAt: issued.Format(time.RFC3339), ExpiresAt: proposal.ExpiresAt} - document.Signature = Sign("team.consent", document.unsigned(), signing) - return document, nil -} - -func VerifyTeam(binding EventBinding, registry Registry, proposal TeamProposal, proposalSourceTime string, consents []TeamConsent, consentSourceTimes map[string]string) (TeamVerification, error) { - if err := registry.RequirePhase("formation_open"); err != nil { - return TeamVerification{}, err - } - proposalAt, err := parseTimestamp(proposalSourceTime) - if err != nil { - return TeamVerification{}, fmt.Errorf("team proposal source time: %w", err) - } - if err := verifyProposal(proposal, binding, registry, proposalAt); err != nil { - return TeamVerification{}, err - } - if err := registry.teamAvailable(proposal.TeamID, proposal.Members); err != nil { - return TeamVerification{}, err - } - if len(consents) != len(proposal.Members) || len(consentSourceTimes) != len(proposal.Members) { - return TeamVerification{}, errors.New("team consents and source times must cover every member") - } - seen := make(map[string]bool, len(consents)) - proposalDigest := DigestJSON(proposal) - for _, consent := range consents { - if seen[consent.ActorID] { - return TeamVerification{}, errors.New("duplicate team consent") - } - atText, found := consentSourceTimes[consent.ActorID] - if !found { - return TeamVerification{}, errors.New("team consent source time is missing") - } - at, parseErr := parseTimestamp(atText) - if parseErr != nil { - return TeamVerification{}, fmt.Errorf("team consent source time: %w", parseErr) - } - if err := verifyConsent(consent, proposal, proposalDigest, binding, registry, at); err != nil { - return TeamVerification{}, err - } - seen[consent.ActorID] = true - } - return TeamVerification{TeamID: proposal.TeamID, ProposalSHA256: proposalDigest, Members: proposal.Members, Verified: true}, nil -} - -func verifyProposal(proposal TeamProposal, binding EventBinding, registry Registry, sourceTime time.Time) error { - if proposal.Version != SchemaVersion || proposal.Kind != "team-proposal" || !uuidV4Pattern.MatchString(proposal.TeamID) { - return errors.New("team proposal is invalid") - } - if err := proposal.Event.Match(binding); err != nil { - return err - } - if uint64(len(proposal.Members)) < binding.Limits.TeamMinimum || uint64(len(proposal.Members)) > binding.Limits.TeamMaximum { - return errors.New("team proposal size is invalid") - } - if err := validateDocumentWindow(proposal.IssuedAt, proposal.ExpiresAt, binding, binding.TTLSeconds.Team, sourceTime); err != nil { - return err - } - var proposer SigningPublic - proposerMatches := false - for index, member := range proposal.Members { - identity, err := registry.Lookup(member.ActorID) - if err != nil || member != memberFromIdentity(identity) { - return errors.New("team proposal member does not match active registry") - } - if index > 0 && compareActorIDs(proposal.Members[index-1].ActorID, member.ActorID) >= 0 { - return errors.New("team proposal members must be strictly sorted") - } - if member == proposal.Proposer { - proposer = identity.SigningKey - proposerMatches = true - } - } - if !proposerMatches { - return errors.New("team proposer is not a proposed member") - } - return Verify("team.propose", proposal.unsigned(), proposal.Signature, proposer) -} - -func verifyConsent(consent TeamConsent, proposal TeamProposal, proposalDigest string, binding EventBinding, registry Registry, sourceTime time.Time) error { - if consent.Version != SchemaVersion || consent.Kind != "team-consent" || consent.TeamID != proposal.TeamID || consent.ProposalSHA256 != proposalDigest || !validActorID(consent.ActorID) || consent.KeyEpoch < 1 || !digestPattern.MatchString(consent.SigningKeyID) { - return errors.New("team consent is invalid") - } - if err := consent.Event.Match(binding); err != nil { - return err - } - if err := validateDocumentWindow(consent.IssuedAt, consent.ExpiresAt, binding, binding.TTLSeconds.Team, sourceTime); err != nil { - return err - } - identity, err := registry.Lookup(consent.ActorID) - if err != nil || identity.KeyEpoch != consent.KeyEpoch || identity.SigningKey.KeyID != consent.SigningKeyID || !lo.ContainsBy(proposal.Members, func(member TeamMember) bool { return member.ActorID == consent.ActorID }) { - return errors.New("team consent signer does not match active proposed member") - } - return Verify("team.consent", consent.unsigned(), consent.Signature, identity.SigningKey) -} - -type Submission struct { - Version int `json:"v"` - Kind string `json:"kind"` - Event EventReference `json:"event"` - TeamID string `json:"team_id"` - AttemptID string `json:"attempt_id"` - ActorID string `json:"actor_id"` - KeyEpoch int `json:"key_epoch"` - SigningKeyID string `json:"signing_kid"` - PayloadSHA256 string `json:"payload_sha256"` - PayloadSize int64 `json:"payload_size"` - MetadataSHA256 string `json:"metadata_sha256,omitempty"` - MetadataSize int64 `json:"metadata_size,omitempty"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` - Signature Signature `json:"signature"` -} - -type submissionUnsigned struct { - Version int `json:"v"` - Kind string `json:"kind"` - Event EventReference `json:"event"` - TeamID string `json:"team_id"` - AttemptID string `json:"attempt_id"` - ActorID string `json:"actor_id"` - KeyEpoch int `json:"key_epoch"` - SigningKeyID string `json:"signing_kid"` - PayloadSHA256 string `json:"payload_sha256"` - PayloadSize int64 `json:"payload_size"` - MetadataSHA256 string `json:"metadata_sha256,omitempty"` - MetadataSize int64 `json:"metadata_size,omitempty"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` -} - -func (submission Submission) unsigned() submissionUnsigned { - return submissionUnsigned{Version: submission.Version, Kind: submission.Kind, Event: submission.Event, TeamID: submission.TeamID, AttemptID: submission.AttemptID, ActorID: submission.ActorID, KeyEpoch: submission.KeyEpoch, SigningKeyID: submission.SigningKeyID, PayloadSHA256: submission.PayloadSHA256, PayloadSize: submission.PayloadSize, MetadataSHA256: submission.MetadataSHA256, MetadataSize: submission.MetadataSize, IssuedAt: submission.IssuedAt, ExpiresAt: submission.ExpiresAt} -} - -func PrepareSubmission(binding EventBinding, teamID, attemptID, actorID string, keyEpoch int, payload, metadata []byte, signing SigningKey, now time.Time) (Submission, error) { - if !uuidV4Pattern.MatchString(teamID) || !uuidV4Pattern.MatchString(attemptID) || !validActorID(actorID) || keyEpoch < 1 { - return Submission{}, errors.New("submission identity is invalid") - } - issuedAt, expiresAt, err := issueWindow(binding, binding.TTLSeconds.Submission, now) - if err != nil { - return Submission{}, err - } - document := Submission{Version: SchemaVersion, Kind: "submission", Event: binding.Reference(), TeamID: teamID, AttemptID: attemptID, ActorID: actorID, KeyEpoch: keyEpoch, SigningKeyID: signing.Public.KeyID, PayloadSHA256: digest(payload), PayloadSize: int64(len(payload)), IssuedAt: issuedAt, ExpiresAt: expiresAt} - if len(metadata) > 0 { - document.MetadataSHA256 = digest(metadata) - document.MetadataSize = int64(len(metadata)) - } - document.Signature = Sign("submission.prepare", document.unsigned(), signing) - return document, nil -} - -func VerifySubmission(document Submission, binding EventBinding, registry Registry, expectedActorID, sourceTime string, payload, metadata []byte) error { - if document.Version != SchemaVersion || document.Kind != "submission" || !uuidV4Pattern.MatchString(document.TeamID) || !uuidV4Pattern.MatchString(document.AttemptID) || !validActorID(document.ActorID) || document.ActorID != expectedActorID || document.KeyEpoch < 1 || !digestPattern.MatchString(document.SigningKeyID) || !digestPattern.MatchString(document.PayloadSHA256) || document.PayloadSize < 0 || (document.MetadataSHA256 != "" && !digestPattern.MatchString(document.MetadataSHA256)) || document.MetadataSize < 0 { - return errors.New("submission is invalid") - } - if err := document.Event.Match(binding); err != nil { - return err - } - at, err := parseTimestamp(sourceTime) - if err != nil { - return fmt.Errorf("submission source time: %w", err) - } - if err := validateDocumentWindow(document.IssuedAt, document.ExpiresAt, binding, binding.TTLSeconds.Submission, at); err != nil { - return err - } - if err := registry.RequirePhase("submissions_open"); err != nil { - return err - } - identity, err := registry.Lookup(document.ActorID) - if err != nil || identity.KeyEpoch != document.KeyEpoch || identity.SigningKey.KeyID != document.SigningKeyID { - return errors.New("submission signer does not match active registry") - } - if document.PayloadSHA256 != digest(payload) || document.PayloadSize != int64(len(payload)) || (document.MetadataSHA256 == "" && len(metadata) != 0) || (document.MetadataSHA256 != "" && (document.MetadataSHA256 != digest(metadata) || document.MetadataSize != int64(len(metadata)))) { - return errors.New("submission payload does not match signed document") - } - team, err := registry.LookupTeam(document.TeamID) - if err != nil || !lo.ContainsBy(team.Members, func(member TeamMember) bool { - return member.ActorID == document.ActorID && member.KeyEpoch == document.KeyEpoch && member.SigningKeyID == document.SigningKeyID - }) { - return errors.New("submission signer is not an active team member") - } - if registry.hasAttempt(document.AttemptID) || uint64(len(registry.Attempts)) >= binding.Limits.AttemptsTotal || registry.attemptsForTeam(document.TeamID) >= binding.Limits.AttemptsPerTeam { - return errors.New("submission attempt is not available") - } - return Verify("submission.prepare", document.unsigned(), document.Signature, identity.SigningKey) -} - -type StreamBinding struct { - Version int `json:"v"` - Kind string `json:"kind"` - Event EventReference `json:"event"` - Purpose string `json:"purpose"` - TeamID string `json:"team_id"` - AttemptID string `json:"attempt_id"` - ArtifactID string `json:"artifact_id"` -} - -func (binding StreamBinding) Validate() error { - if binding.Version != SchemaVersion || binding.Kind != "stream-binding" || binding.Purpose == "" || !uuidV4Pattern.MatchString(binding.TeamID) || !uuidV4Pattern.MatchString(binding.AttemptID) || binding.ArtifactID == "" { - return errors.New("stream binding is invalid") - } - return binding.Event.Validate() -} - -func ReadStreamBinding(path string) (StreamBinding, error) { - var binding StreamBinding - if err := ReadJSON(path, &binding); err != nil { - return binding, fmt.Errorf("read stream binding: %w", err) - } - if err := binding.Validate(); err != nil { - return binding, err - } - return binding, nil -} - -func DigestJSON(value any) string { - return digest(canonicalJSON(value)) -} - -func WriteJSON(path string, value any) error { - return writeExclusive(path, canonicalJSON(value), 0o644) -} - -func ReadJSON(path string, value any) error { - data, err := readFile(path) - if err != nil { - return err - } - return decodeJSON(data, value) -} - -func ReadBytes(path string) ([]byte, error) { return readFile(path) } - -func WriteExclusive(path string, data []byte, mode os.FileMode) error { - return writeExclusive(path, data, mode) -} - -func memberFromIdentity(identity IdentityRecord) TeamMember { - return TeamMember{ActorID: identity.ActorID, KeyEpoch: identity.KeyEpoch, SigningKeyID: identity.SigningKey.KeyID, RecipientKeyID: identity.RecipientKey.KeyID} -} - -func normalizeActorIDs(values []string) []string { - values = lo.Map(values, func(value string, _ int) string { return strings.TrimSpace(value) }) - values = lo.Filter(values, func(value string, _ int) bool { return validActorID(value) }) - values = lo.Uniq(values) - sort.Slice(values, func(left, right int) bool { return compareActorIDs(values[left], values[right]) < 0 }) - return values -} - -func validateRecipientPublic(document *RecipientPublic) error { - if document.Algorithm != "age-hybrid-mlkem768-x25519" || !digestPattern.MatchString(document.KeyID) { - return errors.New("invalid recipient public key metadata") - } - recipient, err := age.ParseHybridRecipient(document.PublicKey) - if err != nil { - return fmt.Errorf("parse recipient public key: %w", err) - } - if document.KeyID != fingerprint([]byte(recipient.String())) { - return errors.New("recipient public key fingerprint mismatch") - } - document.Recipient = recipient - return nil -} - -func decodeSigningPublic(document SigningPublic) (ed25519.PublicKey, error) { - if document.Algorithm != "Ed25519" || !digestPattern.MatchString(document.KeyID) { - return nil, errors.New("invalid signing public key metadata") - } - key, err := base64.RawURLEncoding.DecodeString(document.PublicKey) - if err != nil || len(key) != ed25519.PublicKeySize || document.KeyID != fingerprint(key) { - return nil, errors.New("invalid signing public key") - } - return ed25519.PublicKey(key), nil -} - -func issueWindow(binding EventBinding, ttl uint64, now time.Time) (string, string, error) { - validFrom, _ := parseTimestamp(binding.ValidFrom) - validUntil, _ := parseTimestamp(binding.ValidUntil) - issued := now.UTC().Truncate(time.Second) - expires := issued.Add(time.Duration(ttl) * time.Second) - if issued.Before(validFrom) || expires.After(validUntil) { - return "", "", errors.New("request window falls outside event binding") - } - return issued.Format(time.RFC3339), expires.Format(time.RFC3339), nil -} - -func validateDocumentWindow(issuedText, expiresText string, binding EventBinding, ttl uint64, sourceTime time.Time) error { - issued, err := parseTimestamp(issuedText) - if err != nil { - return fmt.Errorf("invalid issued_at: %w", err) - } - expires, err := parseTimestamp(expiresText) - if err != nil || !expires.After(issued) || expires.Sub(issued) > time.Duration(ttl)*time.Second { - return errors.New("document validity window is invalid") - } - validFrom, _ := parseTimestamp(binding.ValidFrom) - validUntil, _ := parseTimestamp(binding.ValidUntil) - if issued.Before(validFrom) || expires.After(validUntil) || sourceTime.Before(issued) || sourceTime.After(expires) { - return errors.New("document is not valid at trusted source time") - } - return nil -} - -func parseTimestamp(value string) (time.Time, error) { - parsed, err := time.Parse(time.RFC3339, value) - if err != nil || parsed.UTC().Format(time.RFC3339) != value { - return time.Time{}, errors.New("timestamp must be UTC RFC3339 whole seconds") - } - return parsed.UTC(), nil -} - -func validActorID(value string) bool { return decimalPattern.MatchString(value) } - -func compareActorIDs(left, right string) int { - leftValue, _ := strconv.ParseUint(left, 10, 64) - rightValue, _ := strconv.ParseUint(right, 10, 64) - switch { - case leftValue < rightValue: - return -1 - case leftValue > rightValue: - return 1 - default: - return 0 - } -} - -func newUUIDv4() string { - value := make([]byte, 16) - _, _ = rand.Read(value) - value[6] = value[6]&0x0f | 0x40 - value[8] = value[8]&0x3f | 0x80 - return fmt.Sprintf("%x-%x-%x-%x-%x", value[0:4], value[4:6], value[6:8], value[8:10], value[10:16]) -} - -func fingerprint(value []byte) string { - hash := sha256.Sum256(value) - return hex.EncodeToString(hash[:]) -} - -func digest(value []byte) string { - hash := sha256.Sum256(value) - return hex.EncodeToString(hash[:]) -} - -func signedMessage(domain string, value any) []byte { - return append([]byte("eventctl:"+Protocol+":"+domain+"\x00"), canonicalJSON(value)...) -} - -func canonicalJSON(value any) []byte { - // Every caller supplies one of this package's concrete document structs. - // Their fields are JSON-encodable, so json.Marshal cannot fail here. - data := lo.Must(json.Marshal(value)) - return append(data, '\n') -} - -func encryptWithPass(data []byte, passphrase string) []byte { - // readPassphrase has already rejected the only invalid ScryptRecipient - // input, and bytes.Buffer never returns a write error. - recipient := lo.Must(age.NewScryptRecipient(passphrase)) - var output bytes.Buffer - writer := lo.Must(age.Encrypt(&output, recipient)) - lo.Must(writer.Write(data)) - lo.Must0(writer.Close()) - return output.Bytes() -} - -func decryptWithPass(data []byte, passphrase string) ([]byte, error) { - identity := lo.Must(age.NewScryptIdentity(passphrase)) - reader, err := age.Decrypt(bytes.NewReader(data), identity) - if err != nil { - return nil, err - } - plain, err := io.ReadAll(io.LimitReader(reader, MaxBytes+1)) - if err != nil { - return nil, fmt.Errorf("read decrypted key: %w", err) - } - return plain, nil -} - -func readFile(path string) ([]byte, error) { - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() - data, err := io.ReadAll(io.LimitReader(file, MaxBytes+1)) - if err != nil { - return nil, err - } - if len(data) > MaxBytes { - return nil, errors.New("file exceeds size limit") - } - return data, nil -} - -func decodeJSON(data []byte, value any) error { - if err := rejectDuplicateKeys(data); err != nil { - return err - } - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(value); err != nil { - return err - } - return nil -} - -func rejectDuplicateKeys(data []byte) error { - decoder := json.NewDecoder(bytes.NewReader(data)) - if err := scanJSONValue(decoder); err != nil { - return err - } - return requireEOF(decoder) -} - -func scanJSONValue(decoder *json.Decoder) error { - token, err := decoder.Token() - if err != nil { - return err - } - delimiter, isDelimiter := token.(json.Delim) - if !isDelimiter { - return nil - } - if delimiter == '{' { - seen := map[string]bool{} - for decoder.More() { - keyToken, keyErr := decoder.Token() - if keyErr != nil { - return keyErr - } - key, ok := keyToken.(string) - if !ok || seen[key] { - return errors.New("JSON object has duplicate or invalid key") - } - seen[key] = true - if err := scanJSONValue(decoder); err != nil { - return err - } - } - _, err = decoder.Token() - return err - } - // Token returns only opening delimiters at a value boundary. The object - // case returned above, so the remaining delimiter is an array opener. - for decoder.More() { - if err := scanJSONValue(decoder); err != nil { - return err - } - } - _, err = decoder.Token() - return err -} - -func requireEOF(decoder *json.Decoder) error { - _, err := decoder.Token() - if err != io.EOF { - return errors.New("JSON document contains trailing data") - } - return nil -} - -func writeExclusive(path string, data []byte, mode os.FileMode) error { - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) - if err != nil { - return err - } - _, writeErr := file.Write(data) - return errors.Join(writeErr, file.Close()) -} diff --git a/internal/stream/stream.go b/internal/stream/stream.go deleted file mode 100644 index 86eb0be..0000000 --- a/internal/stream/stream.go +++ /dev/null @@ -1,133 +0,0 @@ -package stream - -import ( - "bytes" - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "sort" - - "filippo.io/age" - "github.com/pythonhk/eventctl/internal/protocol" - "github.com/samber/lo" -) - -var magic = [8]byte{'E', 'V', 'T', 'C', 'T', 'L', 1, 0} - -type Header struct { - Protocol string `json:"protocol"` - Binding protocol.StreamBinding `json:"binding"` - Signer protocol.SigningPublic `json:"signer"` - RecipientKeyIDs []string `json:"recipient_key_ids"` - PayloadSize int64 `json:"payload_size"` - PayloadSHA256 string `json:"payload_sha256"` - Signature protocol.Signature `json:"signature"` -} - -type Result struct { - Size int64 `json:"size"` - SHA256 string `json:"sha256"` - RecipientCount int `json:"recipient_count"` -} - -func SealFile(input, output string, binding protocol.StreamBinding, signingKey protocol.SigningKey, recipients []protocol.RecipientPublic, identities []age.Recipient) (Result, error) { - payload, err := protocol.ReadBytes(input) - if err != nil { - return Result{}, fmt.Errorf("read stream: %w", err) - } - digest := sha256.Sum256(payload) - ids := make([]string, len(recipients)) - for index, recipient := range recipients { - ids[index] = recipient.KeyID - } - sort.Strings(ids) - unsigned := Header{Protocol: protocol.Protocol, Binding: binding, Signer: signingKey.Public, RecipientKeyIDs: ids, PayloadSize: int64(len(payload)), PayloadSHA256: hex.EncodeToString(digest[:])} - signature := protocol.Sign("stream.sigcrypt", unsigned, signingKey) - header := unsigned - header.Signature = signature - headerBytes := lo.Must(json.Marshal(header)) - var cipher bytes.Buffer - writer, encryptErr := age.Encrypt(&cipher, identities...) - if encryptErr != nil { - return Result{}, fmt.Errorf("encrypt stream: %w", encryptErr) - } - // The authenticated writer targets bytes.Buffer, whose writes cannot fail. - lo.Must(writer.Write(payload)) - lo.Must0(writer.Close()) - container := make([]byte, 0, len(magic)+4+len(headerBytes)+cipher.Len()) - container = append(container, magic[:]...) - headerLength := [4]byte{} - binary.BigEndian.PutUint32(headerLength[:], uint32(len(headerBytes))) - container = append(container, headerLength[:]...) - container = append(container, headerBytes...) - container = append(container, cipher.Bytes()...) - if err := protocol.WriteExclusive(output, container, 0o600); err != nil { - return Result{}, fmt.Errorf("write encrypted stream: %w", err) - } - return Result{int64(len(payload)), hex.EncodeToString(digest[:]), len(recipients)}, nil -} - -func OpenFile(input, output string, expected protocol.StreamBinding, signer protocol.SigningPublic, recipient protocol.RecipientKey) (Result, error) { - data, err := protocol.ReadBytes(input) - if err != nil { - return Result{}, fmt.Errorf("read encrypted stream: %w", err) - } - if len(data) < len(magic)+4 || !bytes.Equal(data[:len(magic)], magic[:]) { - return Result{}, errors.New("invalid eventctl stream header") - } - headerSize := binary.BigEndian.Uint32(data[len(magic) : len(magic)+4]) - start := len(magic) + 4 - end := start + int(headerSize) - if end > len(data) || headerSize > protocol.MaxBytes { - return Result{}, errors.New("invalid eventctl stream header size") - } - var header Header - if err := json.Unmarshal(data[start:end], &header); err != nil { - return Result{}, fmt.Errorf("decode stream header: %w", err) - } - if header.Protocol != protocol.Protocol || header.Binding != expected { - return Result{}, errors.New("stream binding mismatch") - } - if err := protocol.Verify("stream.sigcrypt", Header{header.Protocol, header.Binding, header.Signer, header.RecipientKeyIDs, header.PayloadSize, header.PayloadSHA256, protocol.Signature{}}, header.Signature, signer); err != nil { - return Result{}, err - } - if header.Signer.KeyID != signer.KeyID { - return Result{}, errors.New("stream signer mismatch") - } - if !contains(header.RecipientKeyIDs, recipient.Public.KeyID) { - return Result{}, errors.New("recipient is not authorized for stream") - } - reader, err := age.Decrypt(bytes.NewReader(data[end:]), recipient.Identity) - if err != nil { - return Result{}, fmt.Errorf("decrypt stream: %w", err) - } - payload, err := io.ReadAll(io.LimitReader(reader, protocol.MaxBytes+1)) - if err != nil { - return Result{}, err - } - if len(payload) > protocol.MaxBytes || int64(len(payload)) != header.PayloadSize { - return Result{}, errors.New("stream size does not match signed header") - } - digest := sha256.Sum256(payload) - actual := hex.EncodeToString(digest[:]) - if actual != header.PayloadSHA256 { - return Result{}, errors.New("stream digest does not match signed header") - } - if err := protocol.WriteExclusive(output, payload, 0o600); err != nil { - return Result{}, fmt.Errorf("write plaintext stream: %w", err) - } - return Result{int64(len(payload)), actual, 1}, nil -} - -func contains(values []string, wanted string) bool { - for _, value := range values { - if value == wanted { - return true - } - } - return false -} diff --git a/mise.toml b/mise.toml index 630b8b7..0cd5e10 100644 --- a/mise.toml +++ b/mise.toml @@ -16,6 +16,7 @@ description = "Run the instrumented CLI E2E suite and enforce 100% coverage" run = """ rm -rf "$E2E_COVERAGE_DIR" "$E2E_COVERAGE_PROFILE" "$E2E_COVERAGE_HTML" mkdir -p "$E2E_COVERAGE_DIR" + go clean -testcache GOCOVERDIR="$PWD/$E2E_COVERAGE_DIR" go test ./tests/e2e -tags=e2e -v -p 1 -count=1 -timeout 10m go tool covdata textfmt -i="$PWD/$E2E_COVERAGE_DIR" -o "$PWD/$E2E_COVERAGE_PROFILE" report="$(go tool cover -func="$PWD/$E2E_COVERAGE_PROFILE")" diff --git a/protocol/protocol.go b/protocol/protocol.go new file mode 100644 index 0000000..71c870e --- /dev/null +++ b/protocol/protocol.go @@ -0,0 +1,833 @@ +// Package protocol implements the portable eventctl/v3 artifact format. +// +// It deliberately knows nothing about GitHub, event policy, registries, or +// scoring. Those are trusted-controller concerns. It only owns local keys, +// signed tar artifacts, and encrypted feedback tar artifacts. +package protocol + +import ( + "archive/tar" + "bytes" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "regexp" + "slices" + "sort" + "strings" + "time" + + "filippo.io/age" + "github.com/samber/lo" +) + +const ( + // Version identifies the artifact protocol, independently from the CLI + // release version. + Version = "eventctl/v3" + + // MaxArtifactBytes bounds one input or decoded tar artifact. Event-specific + // scorers can impose a tighter submission limit. + MaxArtifactBytes = 64 << 20 +) + +var ( + eventIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$`) + githubIDPattern = regexp.MustCompile(`^[1-9][0-9]*$`) + uuidV4Pattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + digestPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) +) + +var epoch = time.Unix(0, 0).UTC() + +// KeyPaths are the four files emitted by key-gen. +type KeyPaths struct { + SignaturePrivate string `json:"signature_private_key"` + SignaturePublic string `json:"signature_public_key"` + EncryptionPrivate string `json:"encryption_private_key"` + EncryptionPublic string `json:"encryption_public_key"` +} + +// File describes one file carried by a public request or encrypted wrapper. +type File struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` +} + +// Manifest is the signed metadata in every eventctl tar artifact. Signature +// signs the canonical Go JSON encoding of this value with Signature omitted. +type Manifest struct { + Protocol string `json:"protocol"` + Kind string `json:"kind"` + EventID string `json:"event_id,omitempty"` + GitHubID string `json:"github_id,omitempty"` + TeamID string `json:"team_id,omitempty"` + TeamName string `json:"team_name,omitempty"` + Members []string `json:"members,omitempty"` + FormationSHA256 string `json:"formation_sha256,omitempty"` + AttemptID string `json:"attempt_id,omitempty"` + Files []File `json:"files"` + SigningPublicKey string `json:"signing_public_key"` + EncryptionPublicKey string `json:"encryption_public_key,omitempty"` + Signature string `json:"signature,omitempty"` +} + +// Artifact is a parsed tar artifact. Files uses tar paths, for example +// files/exploit.whl or payload.age. +type Artifact struct { + Manifest Manifest + Files map[string][]byte + SHA256 string +} + +// EncryptionPublic is an age recipient with its canonical text form. +type EncryptionPublic struct { + Text string + Recipient age.Recipient +} + +// Result describes an artifact written by eventctl. +type Result struct { + Output string `json:"output"` + SHA256 string `json:"sha256"` + TeamID string `json:"team_id,omitempty"` + AttemptID string `json:"attempt_id,omitempty"` + FileCount int `json:"file_count"` + RecipientCount int `json:"recipient_count,omitempty"` +} + +// GenerateKeyDirectory creates an independent Ed25519 signing key pair and +// a native age hybrid encryption key pair. Existing key files are never +// overwritten. +func GenerateKeyDirectory(directory string) (KeyPaths, error) { + paths := KeyPaths{ + SignaturePrivate: filepath.Join(directory, "signature", "key"), + SignaturePublic: filepath.Join(directory, "signature", "key.pub"), + EncryptionPrivate: filepath.Join(directory, "encryption", "key"), + EncryptionPublic: filepath.Join(directory, "encryption", "key.pub"), + } + for _, keyDirectory := range []string{filepath.Dir(paths.SignaturePrivate), filepath.Dir(paths.EncryptionPrivate)} { + if err := os.MkdirAll(keyDirectory, 0o700); err != nil { + return KeyPaths{}, fmt.Errorf("create key directory: %w", err) + } + } + + public, private := lo.Must2(ed25519.GenerateKey(rand.Reader)) + privatePEM := signingPrivatePEM(private) + publicPEM := SigningPublicPEM(public) + identity := lo.Must(age.GenerateHybridIdentity()) + + for _, file := range []struct { + path string + data []byte + mode os.FileMode + }{ + {paths.SignaturePrivate, privatePEM, 0o600}, + {paths.SignaturePublic, []byte(publicPEM), 0o644}, + {paths.EncryptionPrivate, []byte(identity.String() + "\n"), 0o600}, + {paths.EncryptionPublic, []byte(identity.Recipient().String() + "\n"), 0o644}, + } { + if err := writeNew(file.path, file.data, file.mode); err != nil { + return KeyPaths{}, err + } + } + return paths, nil +} + +// LoadSigningPrivate reads an Ed25519 PKCS#8 PEM private key. +func LoadSigningPrivate(file string) (ed25519.PrivateKey, error) { + data, err := readFile(file) + if err != nil { + return nil, fmt.Errorf("read signing private key: %w", err) + } + block, rest := pem.Decode(data) + if block == nil || block.Type != "PRIVATE KEY" || len(bytes.TrimSpace(rest)) != 0 { + return nil, errors.New("invalid signing private key PEM") + } + value, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse signing private key: %w", err) + } + key, ok := value.(ed25519.PrivateKey) + if !ok { + return nil, errors.New("signing private key is not Ed25519") + } + return key, nil +} + +// LoadSigningPublic reads an Ed25519 PKIX PEM public key. +func LoadSigningPublic(file string) (ed25519.PublicKey, error) { + data, err := readFile(file) + if err != nil { + return nil, fmt.Errorf("read signing public key: %w", err) + } + return ParseSigningPublicPEM(string(data)) +} + +// ParseSigningPublicPEM parses an Ed25519 PKIX PEM public key. +func ParseSigningPublicPEM(value string) (ed25519.PublicKey, error) { + block, rest := pem.Decode([]byte(value)) + if block == nil || block.Type != "PUBLIC KEY" || len(bytes.TrimSpace(rest)) != 0 { + return nil, errors.New("invalid signing public key PEM") + } + parsed, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse signing public key: %w", err) + } + key, ok := parsed.(ed25519.PublicKey) + if !ok { + return nil, errors.New("signing public key is not Ed25519") + } + return key, nil +} + +// SigningPublicPEM returns the canonical PKIX PEM representation of an +// Ed25519 public key. +func SigningPublicPEM(key ed25519.PublicKey) string { + data := lo.Must(x509.MarshalPKIXPublicKey(key)) + return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: data})) +} + +// LoadEncryptionPrivate reads a native age hybrid private identity. +func LoadEncryptionPrivate(file string) (*age.HybridIdentity, error) { + data, err := readFile(file) + if err != nil { + return nil, fmt.Errorf("read encryption private key: %w", err) + } + identity, err := age.ParseHybridIdentity(strings.TrimSpace(string(data))) + if err != nil { + return nil, fmt.Errorf("parse encryption private key: %w", err) + } + return identity, nil +} + +// LoadEncryptionPublic reads a native age hybrid recipient public key. +func LoadEncryptionPublic(file string) (EncryptionPublic, error) { + data, err := readFile(file) + if err != nil { + return EncryptionPublic{}, fmt.Errorf("read encryption public key: %w", err) + } + return ParseEncryptionPublic(strings.TrimSpace(string(data))) +} + +// ParseEncryptionPublic parses a native age hybrid recipient public key. +func ParseEncryptionPublic(value string) (EncryptionPublic, error) { + recipient, err := age.ParseHybridRecipient(value) + if err != nil { + return EncryptionPublic{}, fmt.Errorf("parse encryption public key: %w", err) + } + return EncryptionPublic{Text: recipient.String(), Recipient: recipient}, nil +} + +// NewFormation makes an unsigned formation manifest. WriteArtifact signs it. +func NewFormation(eventID, githubID, teamName, signingPublic, encryptionPublic string, members []string) (Manifest, error) { + teamID := NewUUIDV4() + normalized, err := normalizeMembers(members) + if err != nil { + return Manifest{}, err + } + if !contains(normalized, githubID) { + return Manifest{}, errors.New("formation initiator must be a team member") + } + manifest := Manifest{ + Protocol: Version, + Kind: "team-formation", + EventID: eventID, + GitHubID: githubID, + TeamID: teamID, + TeamName: teamName, + Members: normalized, + Files: []File{}, + SigningPublicKey: signingPublic, + EncryptionPublicKey: encryptionPublic, + } + return manifest, validateManifest(manifest) +} + +// NewTeamKey makes an unsigned member key-proof manifest tied to formation. +func NewTeamKey(formation Artifact, githubID, signingPublic, encryptionPublic string) (Manifest, error) { + if err := VerifyArtifactSelf(formation); err != nil { + return Manifest{}, fmt.Errorf("verify formation: %w", err) + } + if formation.Manifest.Kind != "team-formation" { + return Manifest{}, errors.New("formation artifact is not a team formation") + } + if formation.Manifest.GitHubID == githubID { + return Manifest{}, errors.New("formation initiator already supplied a key proof") + } + if !contains(formation.Manifest.Members, githubID) { + return Manifest{}, errors.New("key-proof member is not in the formation") + } + manifest := Manifest{ + Protocol: Version, + Kind: "team-key", + EventID: formation.Manifest.EventID, + GitHubID: githubID, + TeamID: formation.Manifest.TeamID, + FormationSHA256: formation.SHA256, + Files: []File{}, + SigningPublicKey: signingPublic, + EncryptionPublicKey: encryptionPublic, + } + return manifest, validateManifest(manifest) +} + +// NewSubmission makes an unsigned submission manifest. WriteArtifact adds file +// digests and signs it. +func NewSubmission(eventID, githubID, teamID, signingPublic string) (Manifest, error) { + attemptID := NewUUIDV4() + manifest := Manifest{ + Protocol: Version, + Kind: "submission", + EventID: eventID, + GitHubID: githubID, + TeamID: teamID, + AttemptID: attemptID, + Files: []File{}, + SigningPublicKey: signingPublic, + } + if err := validateSubmissionIdentity(manifest); err != nil { + return Manifest{}, err + } + return manifest, nil +} + +// WriteArtifact creates a deterministic public request tar containing +// manifest.json and optional files/ entries. +func WriteArtifact(output string, manifest Manifest, inputPaths []string, signingPrivate ed25519.PrivateKey) (Result, error) { + entries, files := []tarEntry{}, []File{} + if len(inputPaths) > 0 { + var err error + entries, files, err = inputEntries(inputPaths) + if err != nil { + return Result{}, err + } + } + manifest.Files = files + if err := validateManifest(manifest); err != nil { + return Result{}, err + } + manifest = signManifest(manifest, signingPrivate) + manifestData := lo.Must(json.Marshal(manifest)) + entries = append([]tarEntry{{Name: "manifest.json", Data: manifestData, Mode: 0o644}}, entries...) + data, err := marshalTar(entries) + if err != nil { + return Result{}, err + } + if err := writeOutput(output, data, 0o644); err != nil { + return Result{}, err + } + return artifactResult(output, data, manifest, len(files), 0), nil +} + +// ReadArtifact parses an eventctl public or feedback tar without granting any +// authority to the manifest's self-declared signing key. +func ReadArtifact(file string) (Artifact, error) { + data, err := readFile(file) + if err != nil { + return Artifact{}, fmt.Errorf("read artifact: %w", err) + } + entries, err := readTar(data) + if err != nil { + return Artifact{}, fmt.Errorf("read artifact tar: %w", err) + } + manifestData, ok := entries["manifest.json"] + if !ok { + return Artifact{}, errors.New("artifact is missing manifest.json") + } + var manifest Manifest + if err := decodeJSON(manifestData, &manifest); err != nil { + return Artifact{}, fmt.Errorf("decode artifact manifest: %w", err) + } + if err := validateManifest(manifest); err != nil { + return Artifact{}, err + } + delete(entries, "manifest.json") + if err := validateEntries(manifest, entries); err != nil { + return Artifact{}, err + } + digest := sha256.Sum256(data) + return Artifact{Manifest: manifest, Files: entries, SHA256: hex.EncodeToString(digest[:])}, nil +} + +// signManifest signs the manifests constructed by this package. Callers use +// the constructor functions above, which own the artifact shape. +func signManifest(manifest Manifest, signingPrivate ed25519.PrivateKey) Manifest { + manifest.SigningPublicKey = SigningPublicPEM(signingPrivate.Public().(ed25519.PublicKey)) + manifest.Signature = "" + payload := manifestPayload(manifest) + manifest.Signature = base64.RawStdEncoding.EncodeToString(ed25519.Sign(signingPrivate, payload)) + return manifest +} + +// VerifyArtifactSelf verifies an artifact against the public key included in +// its manifest. Controllers must additionally compare that key to registry +// state before accepting the artifact. +func VerifyArtifactSelf(artifact Artifact) error { + return VerifyArtifact(artifact, lo.Must(ParseSigningPublicPEM(artifact.Manifest.SigningPublicKey))) +} + +// VerifyArtifact verifies a parsed artifact against a trusted Ed25519 public +// key. ReadArtifact validates the declared public key before this step. +func VerifyArtifact(artifact Artifact, trusted ed25519.PublicKey) error { + declared := lo.Must(ParseSigningPublicPEM(artifact.Manifest.SigningPublicKey)) + if !bytes.Equal(trusted, declared) { + return errors.New("artifact signing key does not match trusted key") + } + signature, err := base64.RawStdEncoding.DecodeString(artifact.Manifest.Signature) + if err != nil || len(signature) != ed25519.SignatureSize { + return errors.New("artifact signature is invalid") + } + payload := manifestPayload(artifact.Manifest) + if !ed25519.Verify(trusted, payload, signature) { + return errors.New("artifact signature does not verify") + } + return nil +} + +// SealFeedback signs and encrypts input files. The outer feedback.tar is a +// real tar containing manifest.json and payload.age; the encrypted payload is +// a second deterministic tar of files/ entries. +func SealFeedback(inputPaths []string, output string, signingPrivate ed25519.PrivateKey, recipients []age.Recipient) (Result, error) { + entries, _, err := inputEntries(inputPaths) + if err != nil { + return Result{}, err + } + payload, err := marshalTar(entries) + if err != nil { + return Result{}, err + } + var encrypted bytes.Buffer + writer := lo.Must(age.Encrypt(&encrypted, recipients...)) + _, _ = writer.Write(payload) + lo.Must0(writer.Close()) + public := SigningPublicPEM(signingPrivate.Public().(ed25519.PublicKey)) + digest := sha256.Sum256(encrypted.Bytes()) + manifest := Manifest{ + Protocol: Version, + Kind: "feedback", + Files: []File{{Path: "payload.age", SHA256: hex.EncodeToString(digest[:]), Size: int64(encrypted.Len())}}, + SigningPublicKey: public, + } + manifest = signManifest(manifest, signingPrivate) + manifestData := lo.Must(json.Marshal(manifest)) + data := lo.Must(marshalTar([]tarEntry{{Name: "manifest.json", Data: manifestData, Mode: 0o644}, {Name: "payload.age", Data: encrypted.Bytes(), Mode: 0o600}})) + if err := writeOutput(output, data, 0o600); err != nil { + return Result{}, err + } + return artifactResult(output, data, manifest, len(entries), len(recipients)), nil +} + +// OpenFeedback verifies, decrypts, and safely extracts feedback files into an +// initially empty output directory. +func OpenFeedback(input, outputDirectory string, trustedSigningPublic ed25519.PublicKey, encryptionPrivate *age.HybridIdentity) (Result, error) { + artifact, err := ReadArtifact(input) + if err != nil { + return Result{}, err + } + if artifact.Manifest.Kind != "feedback" { + return Result{}, errors.New("artifact is not encrypted feedback") + } + if err := VerifyArtifact(artifact, trustedSigningPublic); err != nil { + return Result{}, err + } + reader, err := age.Decrypt(bytes.NewReader(artifact.Files["payload.age"]), encryptionPrivate) + if err != nil { + return Result{}, fmt.Errorf("decrypt feedback: %w", err) + } + payload, err := io.ReadAll(io.LimitReader(reader, MaxArtifactBytes+1)) + if err != nil { + return Result{}, fmt.Errorf("read feedback payload: %w", err) + } + entries, err := readTar(payload) + if err != nil { + return Result{}, fmt.Errorf("read feedback payload tar: %w", err) + } + if len(entries) == 0 { + return Result{}, errors.New("feedback payload is empty") + } + if err := os.MkdirAll(outputDirectory, 0o700); err != nil { + return Result{}, fmt.Errorf("create feedback output directory: %w", err) + } + contents := lo.Must(os.ReadDir(outputDirectory)) + if len(contents) != 0 { + return Result{}, errors.New("feedback output directory must be empty") + } + names := make([]string, 0, len(entries)) + for name := range entries { + if !isInputPath(name) { + return Result{}, errors.New("feedback payload contains an invalid path") + } + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + target := filepath.Join(outputDirectory, filepath.FromSlash(strings.TrimPrefix(name, "files/"))) + if err := os.WriteFile(target, entries[name], 0o600); err != nil { + return Result{}, fmt.Errorf("write feedback file %s: %w", name, err) + } + } + return Result{Output: outputDirectory, SHA256: artifact.SHA256, FileCount: len(entries)}, nil +} + +// NewUUIDV4 creates the opaque identifier used for teams and attempts. +func NewUUIDV4() string { + value := make([]byte, 16) + _ = lo.Must(rand.Read(value)) + value[6] = value[6]&0x0f | 0x40 + value[8] = value[8]&0x3f | 0x80 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", value[0:4], value[4:6], value[6:8], value[8:10], value[10:16]) +} + +// ValidateEventID validates the repository-slug event identifier accepted by +// the participant CLI. +func ValidateEventID(eventID string) error { + if !eventIDPattern.MatchString(eventID) { + return errors.New("event ID must be an owner/repository slug") + } + return nil +} + +func signingPrivatePEM(key ed25519.PrivateKey) []byte { + data := lo.Must(x509.MarshalPKCS8PrivateKey(key)) + return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: data}) +} + +func writeNew(file string, data []byte, mode os.FileMode) error { + handle, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return fmt.Errorf("write key file %s: %w", file, err) + } + _ = lo.Must(handle.Write(data)) + lo.Must0(handle.Close()) + return nil +} + +func readFile(file string) ([]byte, error) { + info, err := os.Stat(file) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, errors.New("path is not a regular file") + } + if info.Size() > MaxArtifactBytes { + return nil, errors.New("file exceeds size limit") + } + return os.ReadFile(file) +} + +func normalizeMembers(members []string) ([]string, error) { + if len(members) == 0 { + return nil, errors.New("at least one team member is required") + } + for _, member := range members { + if !githubIDPattern.MatchString(member) { + return nil, errors.New("GitHub IDs must be positive decimal numbers") + } + } + unique := lo.Uniq(members) + if len(unique) != len(members) { + return nil, errors.New("team members must not repeat") + } + sort.Slice(unique, func(left, right int) bool { + if len(unique[left]) != len(unique[right]) { + return len(unique[left]) < len(unique[right]) + } + return unique[left] < unique[right] + }) + return unique, nil +} + +func validateManifest(manifest Manifest) error { + if manifest.Protocol != Version { + return errors.New("artifact protocol is invalid") + } + if _, err := ParseSigningPublicPEM(manifest.SigningPublicKey); err != nil { + return err + } + switch manifest.Kind { + case "team-formation": + if err := validateFormation(manifest); err != nil { + return err + } + case "team-key": + if err := validateKey(manifest); err != nil { + return err + } + case "submission": + if err := validateSubmission(manifest); err != nil { + return err + } + case "feedback": + if err := validateFeedback(manifest); err != nil { + return err + } + default: + return errors.New("artifact kind is invalid") + } + return nil +} + +func validateFormation(manifest Manifest) error { + if err := validateTeamIdentity(manifest); err != nil { + return err + } + if !validText(manifest.TeamName) || manifest.FormationSHA256 != "" || manifest.AttemptID != "" || len(manifest.Files) != 0 { + return errors.New("team formation fields are invalid") + } + members, err := normalizeMembers(manifest.Members) + if err != nil || !slices.Equal(members, manifest.Members) || !contains(members, manifest.GitHubID) { + return errors.New("team formation members are invalid") + } + if _, err := ParseEncryptionPublic(manifest.EncryptionPublicKey); err != nil { + return err + } + return nil +} + +func validateKey(manifest Manifest) error { + if err := validateTeamIdentity(manifest); err != nil { + return err + } + if !digestPattern.MatchString(manifest.FormationSHA256) || manifest.TeamName != "" || len(manifest.Members) != 0 || manifest.AttemptID != "" || len(manifest.Files) != 0 { + return errors.New("team key fields are invalid") + } + if _, err := ParseEncryptionPublic(manifest.EncryptionPublicKey); err != nil { + return err + } + return nil +} + +func validateSubmissionIdentity(manifest Manifest) error { + if err := validateTeamIdentity(manifest); err != nil { + return err + } + if !uuidV4Pattern.MatchString(manifest.AttemptID) || manifest.TeamName != "" || len(manifest.Members) != 0 || manifest.FormationSHA256 != "" || manifest.EncryptionPublicKey != "" { + return errors.New("submission identity fields are invalid") + } + return nil +} + +func validateSubmission(manifest Manifest) error { + if err := validateSubmissionIdentity(manifest); err != nil { + return err + } + if len(manifest.Files) == 0 || !validFiles(manifest.Files, isInputPath) { + return errors.New("submission files are invalid") + } + return nil +} + +func validateFeedback(manifest Manifest) error { + if manifest.EventID != "" || manifest.GitHubID != "" || manifest.TeamID != "" || manifest.TeamName != "" || len(manifest.Members) != 0 || manifest.FormationSHA256 != "" || manifest.AttemptID != "" || manifest.EncryptionPublicKey != "" { + return errors.New("feedback identity fields are invalid") + } + if len(manifest.Files) != 1 || !validFiles(manifest.Files, func(value string) bool { return value == "payload.age" }) { + return errors.New("feedback files are invalid") + } + return nil +} + +func validateTeamIdentity(manifest Manifest) error { + if err := ValidateEventID(manifest.EventID); err != nil { + return err + } + if !githubIDPattern.MatchString(manifest.GitHubID) || !uuidV4Pattern.MatchString(manifest.TeamID) { + return errors.New("artifact team identity is invalid") + } + return nil +} + +func validFiles(files []File, allowed func(string) bool) bool { + for index, file := range files { + if !allowed(file.Path) || !digestPattern.MatchString(file.SHA256) || file.Size < 0 || (index > 0 && files[index-1].Path >= file.Path) { + return false + } + } + return true +} + +func validText(value string) bool { + if strings.TrimSpace(value) == "" { + return false + } + for _, character := range value { + if character < 0x20 || character == 0x7f { + return false + } + } + return true +} + +func contains(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} + +func manifestPayload(manifest Manifest) []byte { + manifest.Signature = "" + data := lo.Must(json.Marshal(manifest)) + return append([]byte(Version+"\x00"+manifest.Kind+"\x00"), data...) +} + +type tarEntry struct { + Name string + Data []byte + Mode int64 +} + +func inputEntries(inputPaths []string) ([]tarEntry, []File, error) { + if len(inputPaths) == 0 { + return nil, nil, errors.New("at least one input file is required") + } + entries := make([]tarEntry, 0, len(inputPaths)) + seen := make(map[string]bool, len(inputPaths)) + for _, input := range inputPaths { + data, err := readFile(input) + if err != nil { + return nil, nil, fmt.Errorf("read input %s: %w", input, err) + } + name := path.Join("files", filepath.Base(input)) + if !isInputPath(name) || seen[name] { + return nil, nil, errors.New("input file names must be unique simple file names") + } + seen[name] = true + entries = append(entries, tarEntry{Name: name, Data: data, Mode: 0o644}) + } + sort.Slice(entries, func(left, right int) bool { return entries[left].Name < entries[right].Name }) + files := make([]File, 0, len(entries)) + for _, entry := range entries { + digest := sha256.Sum256(entry.Data) + files = append(files, File{Path: entry.Name, SHA256: hex.EncodeToString(digest[:]), Size: int64(len(entry.Data))}) + } + return entries, files, nil +} + +func marshalTar(entries []tarEntry) ([]byte, error) { + sorted := append([]tarEntry(nil), entries...) + sort.Slice(sorted, func(left, right int) bool { return sorted[left].Name < sorted[right].Name }) + var buffer bytes.Buffer + writer := tar.NewWriter(&buffer) + for _, entry := range sorted { + if !safeTarName(entry.Name) { + return nil, errors.New("tar entry path is invalid") + } + header := &tar.Header{Name: entry.Name, Mode: entry.Mode, Size: int64(len(entry.Data)), Typeflag: tar.TypeReg, Format: tar.FormatUSTAR, ModTime: epoch} + lo.Must0(writer.WriteHeader(header)) + _, _ = writer.Write(entry.Data) + } + lo.Must0(writer.Close()) + return buffer.Bytes(), nil +} + +func readTar(data []byte) (map[string][]byte, error) { + reader := tar.NewReader(bytes.NewReader(data)) + entries := map[string][]byte{} + var total int64 + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + return entries, nil + } + if err != nil { + return nil, err + } + if header.Typeflag != tar.TypeReg || !safeTarName(header.Name) || header.Size < 0 || header.Size > MaxArtifactBytes-total { + return nil, errors.New("tar entry is invalid") + } + if _, exists := entries[header.Name]; exists { + return nil, errors.New("tar contains duplicate path") + } + entry := make([]byte, header.Size) + if _, err := io.ReadFull(reader, entry); err != nil { + return nil, err + } + total += header.Size + entries[header.Name] = entry + } +} + +func validateEntries(manifest Manifest, entries map[string][]byte) error { + if manifest.Kind == "feedback" { + if len(entries) != 1 || entries["payload.age"] == nil { + return errors.New("feedback tar entries are invalid") + } + return validateFileContent(manifest.Files[0], entries["payload.age"]) + } + if len(entries) != len(manifest.Files) { + return errors.New("artifact tar entries do not match manifest") + } + for _, file := range manifest.Files { + data, exists := entries[file.Path] + if !exists { + return errors.New("artifact is missing a manifest file") + } + if err := validateFileContent(file, data); err != nil { + return err + } + } + return nil +} + +func validateFileContent(file File, data []byte) error { + if int64(len(data)) != file.Size { + return errors.New("artifact file size does not match manifest") + } + digest := sha256.Sum256(data) + if hex.EncodeToString(digest[:]) != file.SHA256 { + return errors.New("artifact file digest does not match manifest") + } + return nil +} + +func safeTarName(value string) bool { + return value != "" && !strings.HasPrefix(value, "/") && !strings.Contains(value, "\\") && path.Clean(value) == value && !strings.HasPrefix(value, "../") && value != ".." +} + +func isInputPath(value string) bool { + return strings.HasPrefix(value, "files/") && strings.Count(value, "/") == 1 && path.Base(value) != "." && path.Base(value) != ".." +} + +func writeOutput(output string, data []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil { + return fmt.Errorf("create output directory: %w", err) + } + if err := os.WriteFile(output, data, mode); err != nil { + return fmt.Errorf("write output %s: %w", output, err) + } + return nil +} + +func artifactResult(output string, data []byte, manifest Manifest, fileCount, recipientCount int) Result { + digest := sha256.Sum256(data) + return Result{Output: output, SHA256: hex.EncodeToString(digest[:]), TeamID: manifest.TeamID, AttemptID: manifest.AttemptID, FileCount: fileCount, RecipientCount: recipientCount} +} + +func decodeJSON(data []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("unexpected trailing JSON") + } + return nil +} diff --git a/tests/e2e/eventctl_test.go b/tests/e2e/eventctl_test.go index f3f2961..fb03aba 100644 --- a/tests/e2e/eventctl_test.go +++ b/tests/e2e/eventctl_test.go @@ -3,17 +3,23 @@ package e2e import ( + "archive/tar" "bytes" + "crypto/ecdsa" "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" "encoding/base64" - "encoding/binary" "encoding/json" - "fmt" + "encoding/pem" "io" "os" "os/exec" "path/filepath" "runtime" + "sort" "strings" "testing" @@ -21,95 +27,76 @@ import ( "github.com/stretchr/testify/suite" ) -type response struct { +type cliResponse struct { OK bool `json:"ok"` Command string `json:"command"` Result json.RawMessage `json:"result"` Error string `json:"error"` } -type keyResult struct { - SigningPrivate string `json:"signing_private_key"` - SigningPublic string `json:"signing_public_key"` - RecipientPrivate string `json:"recipient_private_key"` - RecipientPublic string `json:"recipient_public_key"` - SigningKeyID string `json:"signing_key_id"` - RecipientKeyID string `json:"recipient_key_id"` +type keyPaths struct { + SignaturePrivate string `json:"signature_private_key"` + SignaturePublic string `json:"signature_public_key"` + EncryptionPrivate string `json:"encryption_private_key"` + EncryptionPublic string `json:"encryption_public_key"` } -type streamEventReference struct { - EventID string `json:"event_id"` - EventEpoch int `json:"event_epoch"` - RepositoryID string `json:"repository_id"` - BindingSHA256 string `json:"binding_sha256"` +type artifactResult struct { + Output string `json:"output"` + SHA256 string `json:"sha256"` + TeamID string `json:"team_id"` + AttemptID string `json:"attempt_id"` + FileCount int `json:"file_count"` + RecipientCount int `json:"recipient_count"` } -type streamBinding struct { - Version int `json:"v"` - Kind string `json:"kind"` - Event streamEventReference `json:"event"` - Purpose string `json:"purpose"` - TeamID string `json:"team_id"` - AttemptID string `json:"attempt_id"` - ArtifactID string `json:"artifact_id"` +type manifestFile struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` } -type streamSigningPublic struct { - Algorithm string `json:"alg"` - KeyID string `json:"kid"` - PublicKey string `json:"public"` +type manifest struct { + Protocol string `json:"protocol"` + Kind string `json:"kind"` + EventID string `json:"event_id,omitempty"` + GitHubID string `json:"github_id,omitempty"` + TeamID string `json:"team_id,omitempty"` + TeamName string `json:"team_name,omitempty"` + Members []string `json:"members,omitempty"` + FormationSHA256 string `json:"formation_sha256,omitempty"` + AttemptID string `json:"attempt_id,omitempty"` + Files []manifestFile `json:"files"` + SigningPublicKey string `json:"signing_public_key"` + EncryptionPublicKey string `json:"encryption_public_key,omitempty"` + Signature string `json:"signature,omitempty"` } -type streamSignature struct { - Algorithm string `json:"alg"` - KeyID string `json:"kid"` - Value string `json:"value"` -} - -type streamHeader struct { - Protocol string `json:"protocol"` - Binding streamBinding `json:"binding"` - Signer streamSigningPublic `json:"signer"` - RecipientKeyIDs []string `json:"recipient_key_ids"` - PayloadSize int64 `json:"payload_size"` - PayloadSHA256 string `json:"payload_sha256"` - Signature streamSignature `json:"signature"` +type rawTarEntry struct { + Name string + Data []byte + Type byte } type eventSuite struct { suite.Suite - root string - work string - binary string - coverage string - passphrase string - event string - registry string - submissionRegistry string - bundle string - metadata string - captain keyResult - teammate keyResult - outsider keyResult - captainReg string - teammateReg string - proposal string - captainConsent string - teammateConsent string - teamVerification string - teamID string - attemptID string - submission string - streamBinding string - stream string + root string + work string + binary string + coverage string + alice keyPaths + bob keyPaths + outsider keyPaths + input string + log string } func TestEventctl(t *testing.T) { suite.Run(t, new(eventSuite)) } func (s *eventSuite) SetupSuite() { - _, currentFile, _, ok := runtime.Caller(0) + _, file, _, ok := runtime.Caller(0) s.Require().True(ok) - s.root = filepath.Clean(filepath.Join(filepath.Dir(currentFile), "../..")) + s.root = filepath.Clean(filepath.Join(filepath.Dir(file), "../..")) s.work = s.T().TempDir() s.binary = filepath.Join(s.work, "eventctl") s.coverage = os.Getenv("GOCOVERDIR") @@ -117,987 +104,685 @@ func (s *eventSuite) SetupSuite() { s.coverage = filepath.Join(s.work, "coverage") } s.Require().NoError(os.MkdirAll(s.coverage, 0o755)) + build := exec.Command("go", "build", "-cover", "-coverpkg=./...", "-o", s.binary, "./cmd/eventctl") build.Dir = s.root build.Env = append(os.Environ(), "GOCOVERDIR="+s.coverage) - buildOutput, err := build.CombinedOutput() - s.Require().NoError(err, string(buildOutput)) - - s.passphrase = filepath.Join(s.work, "passphrase.txt") - s.Require().NoError(os.WriteFile(s.passphrase, []byte("correct horse battery staple\n"), 0o600)) - s.event = filepath.Join(s.work, "event.json") - s.writeJSON(s.event, map[string]any{ - "v": 2, - "kind": "event-binding", - "protocol": "eventctl/v2", - "event_id": "pycon-hk-2026", - "event_epoch": 1, - "repository_id": "12345", - "valid_from": "2026-01-01T00:00:00Z", - "valid_until": "2030-01-01T00:00:00Z", - "terms_sha256": strings.Repeat("a", 64), - "ttl_seconds": map[string]any{"registration": 604800, "team": 604800, "submission": 86400}, - "limits": map[string]any{"team_min": 1, "team_max": 5, "attempts_per_team": 10, "attempts_total": 200}, - }) - s.bundle = filepath.Join(s.work, "exploit-package.zip") - s.metadata = filepath.Join(s.work, "metadata.json") - s.Require().NoError(os.WriteFile(s.bundle, []byte("safe exploit package\n"), 0o600)) - s.Require().NoError(os.WriteFile(s.metadata, []byte(`{"lab":1,"language":"python"}`), 0o600)) - - s.captain = s.keyGen("captain") - s.teammate = s.keyGen("teammate") + output, err := build.CombinedOutput() + s.Require().NoError(err, string(output)) + + s.alice = s.keyGen("alice") + s.bob = s.keyGen("bob") s.outsider = s.keyGen("outsider") - s.captainReg = s.register("100", "10000000-0000-4000-8000-000000000001", s.captain) - s.teammateReg = s.register("200", "20000000-0000-4000-8000-000000000002", s.teammate) - s.registry = filepath.Join(s.work, "registry.json") - identities := s.verifiedIdentities(s.captainReg, s.teammateReg) - s.writeRegistry(s.registry, "formation_open", identities, nil, nil) - s.teamID = "30000000-0000-4000-8000-000000000003" - s.attemptID = "40000000-0000-4000-8000-000000000004" - s.proposal = filepath.Join(s.work, "proposal.json") - s.success("team", "propose", "--event", s.event, "--registry", s.registry, "--team-id", s.teamID, "--actor-id", "100", "--member", "100", "--member", "200", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", s.proposal) - s.captainConsent = filepath.Join(s.work, "captain-consent.json") - s.teammateConsent = filepath.Join(s.work, "teammate-consent.json") - s.success("team", "consent", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", s.captainConsent) - s.success("team", "consent", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--actor-id", "200", "--sig-private-key", s.teammate.SigningPrivate, "--passphrase-file", s.passphrase, "--output", s.teammateConsent) - s.teamVerification = filepath.Join(s.work, "team-verification.json") - s.success("team", "verify", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--proposal-source-time", s.issuedAt(s.proposal), "--consent", s.captainConsent, "--consent", s.teammateConsent, "--consent-source-time", "100="+s.issuedAt(s.captainConsent), "--consent-source-time", "200="+s.issuedAt(s.teammateConsent), "--output", s.teamVerification) - s.submissionRegistry = filepath.Join(s.work, "submission-registry.json") - verification := s.readObject(s.teamVerification) - s.writeRegistry(s.submissionRegistry, "submissions_open", identities, []any{map[string]any{"team_id": verification["team_id"], "proposal_sha256": verification["proposal_sha256"], "members": verification["members"]}}, nil) - s.submission = filepath.Join(s.work, "submission.json") - s.success("submission", "prepare", "--event", s.event, "--input", s.bundle, "--metadata", s.metadata, "--team-id", s.teamID, "--attempt-id", s.attemptID, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", s.submission) - s.streamBinding = filepath.Join(s.work, "stream-binding.json") - s.writeJSON(s.streamBinding, map[string]any{ - "v": 2, - "kind": "stream-binding", - "event": s.readObject(s.captainReg)["event"], - "purpose": "judge-log", - "team_id": s.teamID, - "attempt_id": s.attemptID, - "artifact_id": "run-1", - }) - judgeLog := filepath.Join(s.work, "judge.log") - s.Require().NoError(os.WriteFile(judgeLog, []byte("private judge output\n"), 0o600)) - s.stream = filepath.Join(s.work, "judge.log.eventctl") - s.success("sigcrypt", "--input", judgeLog, "--output", s.stream, "--context", s.streamBinding, "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--enc-public-key", s.captain.RecipientPublic, "--enc-public-key", s.teammate.RecipientPublic, "--enc-public-key", s.captain.RecipientPublic) + s.input = s.write("exploit.whl", "wheel payload\n") + s.log = s.write("judge.log", "private judge output\n") } func (s *eventSuite) TearDownSuite() { entries, err := os.ReadDir(s.coverage) s.Require().NoError(err) - meta, counters := false, false + metadata, counters := false, false for _, entry := range entries { - meta = meta || strings.HasPrefix(entry.Name(), "covmeta.") + metadata = metadata || strings.HasPrefix(entry.Name(), "covmeta.") counters = counters || strings.HasPrefix(entry.Name(), "covcounters.") } - s.Require().True(meta, "instrumented binary did not emit covmeta") - s.Require().True(counters, "instrumented binary did not emit covcounters") + s.True(metadata, "instrumented binary did not emit covmeta") + s.True(counters, "instrumented binary did not emit covcounters") } -func (s *eventSuite) TestVersionDoctorAndEventBinding() { +func (s *eventSuite) TestVersionAndDoctor() { version := s.success("version") - s.Contains(string(version.Result), `"protocol":"eventctl/v2"`) - defaultDoctor := s.successWithEnv(map[string]string{"EVENTCTL_EVENT_ID": "weekend-1", "EVENTCTL_EVENT_EPOCH": "2"}, "doctor") - s.Contains(string(defaultDoctor.Result), `"event_id":"weekend-1"`) - eventDoctor := s.success("doctor", "--event", s.event) - s.Contains(string(eventDoctor.Result), `"repository_id":"12345"`) - registryDoctor := s.success("doctor", "--event", s.event, "--registry", s.submissionRegistry) - s.Contains(string(registryDoctor.Result), `"phase":"submissions_open"`) - s.failure("doctor", "--registry", s.registry) - s.failure("doctor", "--event", s.event, "--registry", filepath.Join(s.work, "missing-registry.json")) - s.failureWithEnv(map[string]string{"EVENTCTL_EVENT_EPOCH": "0"}, "doctor") - s.failureWithEnv(map[string]string{"EVENTCTL_EVENT_EPOCH": "not-a-number"}, "doctor") + s.Contains(string(version.Result), `"protocol":"eventctl/v3"`) + s.Contains(string(version.Result), `"version":"0.3.0-dev"`) + + doctor := s.successWithEnv(map[string]string{"EVENTCTL_EVENT_ID": "pythonhk/event-smoke"}, "doctor") + s.Contains(string(doctor.Result), `"event_id":"pythonhk/event-smoke"`) + s.Contains(string(doctor.Result), `"signature":"Ed25519"`) + s.success("doctor") + s.failureWithEnv(map[string]string{"EVENTCTL_EVENT_ID": "not-a-repository"}, "doctor") + s.failure("doctor", "--unexpected") s.raw(0, "--help") + s.raw(0, "team", "--help") } -func (s *eventSuite) TestPublicBindingAndJSONFailures() { - cases := []struct { - name string - mutate func(map[string]any) +func (s *eventSuite) TestKeyGenerationFilesArePlainAndExclusive() { + for _, file := range []struct { + path string + mode os.FileMode }{ - {"discriminator", func(value map[string]any) { value["kind"] = "wrong" }}, - {"event-identity", func(value map[string]any) { value["event_id"] = "NO" }}, - {"valid-from", func(value map[string]any) { value["valid_from"] = "not-a-time" }}, - {"valid-until", func(value map[string]any) { value["valid_until"] = "2025-01-01T00:00:00Z" }}, - {"ttl", func(value map[string]any) { value["ttl_seconds"].(map[string]any)["team"] = 0 }}, - {"limits", func(value map[string]any) { value["limits"].(map[string]any)["team_max"] = 0 }}, - } - for _, test := range cases { - s.Run(test.name, func() { - path := filepath.Join(s.work, "bad-event-"+test.name+".json") - value := s.copyObject(s.event) - test.mutate(value) - s.writeJSON(path, value) - s.failure("doctor", "--event", path) - }) - } - - s.failure("doctor", "--event", filepath.Join(s.work, "missing-event.json")) - s.failure("doctor", "--event", s.work) - - tooLarge := filepath.Join(s.work, "too-large-event.json") - file, err := os.Create(tooLarge) - s.Require().NoError(err) - s.Require().NoError(file.Truncate((64 << 20) + 1)) - s.Require().NoError(file.Close()) - s.failure("doctor", "--event", tooLarge) - - for name, data := range map[string][]byte{ - "empty": nil, - "invalid": []byte("{"), - "key": []byte(`{"event"`), - "invalid-key": []byte(`{"event`), - "primitive": []byte("1"), - "nested-object": []byte(`{"event":{`), - "nested-array": []byte(`{"items":[`), - "invalid-element": []byte(`{"items":[}`), - "invalid-array": []byte(`{"items":[1,]}`), - "duplicate": []byte(`{"v":2,"v":2}`), - "trailing": append(bytes.TrimSpace(s.readBytes(s.event)), []byte("\n{}")...), - "unknown": append(bytes.TrimSuffix(bytes.TrimSpace(s.readBytes(s.event)), []byte("}")), []byte(`,"unexpected":true}`)...), + {s.alice.SignaturePrivate, 0o600}, + {s.alice.SignaturePublic, 0o644}, + {s.alice.EncryptionPrivate, 0o600}, + {s.alice.EncryptionPublic, 0o644}, } { - path := filepath.Join(s.work, "bad-json-"+name+".json") - s.Require().NoError(os.WriteFile(path, data, 0o600)) - s.failure("doctor", "--event", path) - } - - for name, mutate := range map[string]func(map[string]any){ - "invalid-reference": func(value map[string]any) { - value["event"].(map[string]any)["event_id"] = "bad" - }, - "mismatched-reference": func(value map[string]any) { - value["event"].(map[string]any)["binding_sha256"] = strings.Repeat("b", 64) - }, - } { - path := filepath.Join(s.work, "bad-registration-event-"+name+".json") - value := s.copyObject(s.captainReg) - mutate(value) - s.writeJSON(path, value) - s.failure("identity", "verify", "--event", s.event, "--input", path, "--expect-actor-id", "100", "--source-time", s.issuedAt(s.captainReg), "--output", filepath.Join(s.work, "bad-registration-event-"+name+".verified.json")) - } -} - -func (s *eventSuite) TestCommandInputFailures() { - missingPassphrase := filepath.Join(s.work, "missing-passphrase.txt") - fileOutput := filepath.Join(s.work, "not-a-directory") - s.Require().NoError(os.WriteFile(fileOutput, []byte("file"), 0o600)) - s.failure("key-gen", "--out", filepath.Join(s.work, "missing-passphrase-key"), "--passphrase-file", missingPassphrase) - s.failure("key-gen", "--out", filepath.Join(s.work, "captain"), "--passphrase-file", s.passphrase) - s.failure("key-gen", "--out", fileOutput, "--passphrase-file", s.passphrase) - - badRecipientPublic := filepath.Join(s.work, "bad-recipient.public.json") - s.writeJSON(badRecipientPublic, map[string]any{"alg": "wrong", "kid": strings.Repeat("a", 64), "recipient": "wrong"}) - badSigningPublic := filepath.Join(s.work, "bad-signing.public.json") - s.writeJSON(badSigningPublic, map[string]any{"alg": "wrong", "kid": strings.Repeat("a", 64), "public": "wrong"}) - badSigningPrivate := filepath.Join(s.work, "bad-signing.private.age") - s.writePassphraseEncrypted(badSigningPrivate, []byte(`{"kind":"wrong"}`)) - badRecipientPrivate := filepath.Join(s.work, "bad-recipient.private.age") - s.writePassphraseEncrypted(badRecipientPrivate, []byte(`{"kind":"wrong"}`)) - - missingStreamBinding := filepath.Join(s.work, "missing-stream-binding.json") - s.failure("sigcrypt", "--input", s.bundle, "--output", filepath.Join(s.work, "missing-context.eventctl"), "--context", missingStreamBinding, "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--enc-public-key", s.captain.RecipientPublic) - s.failure("sigcrypt", "--input", s.bundle, "--output", filepath.Join(s.work, "missing-pass.eventctl"), "--context", s.streamBinding, "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", missingPassphrase, "--enc-public-key", s.captain.RecipientPublic) - s.failure("sigcrypt", "--input", s.bundle, "--output", filepath.Join(s.work, "missing-signing.eventctl"), "--context", s.streamBinding, "--sig-private-key", filepath.Join(s.work, "missing-signing.private.age"), "--passphrase-file", s.passphrase, "--enc-public-key", s.captain.RecipientPublic) - s.failure("sigcrypt", "--input", s.bundle, "--output", filepath.Join(s.work, "bad-signing.eventctl"), "--context", s.streamBinding, "--sig-private-key", badSigningPrivate, "--passphrase-file", s.passphrase, "--enc-public-key", s.captain.RecipientPublic) - s.failure("sigcrypt", "--input", s.bundle, "--output", filepath.Join(s.work, "bad-recipient.eventctl"), "--context", s.streamBinding, "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--enc-public-key", badRecipientPublic) - s.failure("sigcrypt", "--input", filepath.Join(s.work, "missing-input"), "--output", filepath.Join(s.work, "missing-input.eventctl"), "--context", s.streamBinding, "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--enc-public-key", s.captain.RecipientPublic) - s.failure("sigcrypt", "--input", s.bundle, "--output", s.stream, "--context", s.streamBinding, "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--enc-public-key", s.captain.RecipientPublic) - - s.failure("decverify", "--input", s.stream, "--output", filepath.Join(s.work, "missing-decrypt-context"), "--context", missingStreamBinding, "--ver-public-key", s.captain.SigningPublic, "--dec-private-key", s.captain.RecipientPrivate, "--passphrase-file", s.passphrase) - s.failure("decverify", "--input", s.stream, "--output", filepath.Join(s.work, "missing-public"), "--context", s.streamBinding, "--ver-public-key", filepath.Join(s.work, "missing-signing.public.json"), "--dec-private-key", s.captain.RecipientPrivate, "--passphrase-file", s.passphrase) - s.failure("decverify", "--input", s.stream, "--output", filepath.Join(s.work, "bad-public"), "--context", s.streamBinding, "--ver-public-key", badSigningPublic, "--dec-private-key", s.captain.RecipientPrivate, "--passphrase-file", s.passphrase) - s.failure("decverify", "--input", s.stream, "--output", filepath.Join(s.work, "missing-decrypt-pass"), "--context", s.streamBinding, "--ver-public-key", s.captain.SigningPublic, "--dec-private-key", s.captain.RecipientPrivate, "--passphrase-file", missingPassphrase) - s.failure("decverify", "--input", s.stream, "--output", filepath.Join(s.work, "missing-recipient"), "--context", s.streamBinding, "--ver-public-key", s.captain.SigningPublic, "--dec-private-key", filepath.Join(s.work, "missing-recipient.private.age"), "--passphrase-file", s.passphrase) - s.failure("decverify", "--input", s.stream, "--output", filepath.Join(s.work, "bad-recipient"), "--context", s.streamBinding, "--ver-public-key", s.captain.SigningPublic, "--dec-private-key", badRecipientPrivate, "--passphrase-file", s.passphrase) - s.failure("decverify", "--input", filepath.Join(s.work, "missing-encrypted-stream"), "--output", filepath.Join(s.work, "missing-encrypted-output"), "--context", s.streamBinding, "--ver-public-key", s.captain.SigningPublic, "--dec-private-key", s.captain.RecipientPrivate, "--passphrase-file", s.passphrase) - - s.identityCommandFailures(missingPassphrase, badSigningPrivate, badRecipientPublic) - s.teamCommandFailures(missingPassphrase, badSigningPrivate) - s.submissionCommandFailures(missingPassphrase, badSigningPrivate) -} - -func (s *eventSuite) TestKeyMaterialRejections() { - invalidCiphertext := filepath.Join(s.work, "invalid-ciphertext.age") - s.Require().NoError(os.WriteFile(invalidCiphertext, []byte("not an age file"), 0o600)) - corruptCiphertext := filepath.Join(s.work, "corrupt-ciphertext.age") - corrupt := append([]byte(nil), s.readBytes(s.captain.SigningPrivate)...) - corrupt[len(corrupt)-1] ^= 1 - s.Require().NoError(os.WriteFile(corruptCiphertext, corrupt, 0o600)) - invalidJSON := filepath.Join(s.work, "invalid-json.age") - s.writePassphraseEncrypted(invalidJSON, []byte("{")) - _, private, err := ed25519.GenerateKey(nil) - s.Require().NoError(err) - metadataMismatch := filepath.Join(s.work, "metadata-mismatch-signing.age") - s.writePassphraseEncrypted(metadataMismatch, []byte(`{"kind":"wrong","alg":"Ed25519","kid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","private":"`+base64.RawURLEncoding.EncodeToString(private)+`"}`)) - - register := func(signingPath, recipientPath, output string) { - s.failure("identity", "register", "--event", s.event, "--actor-id", "100", "--registration-id", "50000000-0000-4000-8000-000000000005", "--sig-private-key", signingPath, "--recipient-public-key", recipientPath, "--passphrase-file", s.passphrase, "--output", output) - } - register(invalidCiphertext, s.captain.RecipientPublic, filepath.Join(s.work, "invalid-signing-cipher.json")) - register(corruptCiphertext, s.captain.RecipientPublic, filepath.Join(s.work, "corrupt-signing-cipher.json")) - register(invalidJSON, s.captain.RecipientPublic, filepath.Join(s.work, "invalid-signing-json.json")) - register(metadataMismatch, s.captain.RecipientPublic, filepath.Join(s.work, "invalid-signing-metadata.json")) - - badRecipientParse := filepath.Join(s.work, "bad-recipient-parse.json") - s.writeJSON(badRecipientParse, map[string]any{"alg": "age-hybrid-mlkem768-x25519", "kid": strings.Repeat("a", 64), "recipient": "not-a-recipient"}) - recipientIdentity, err := age.GenerateHybridIdentity() - s.Require().NoError(err) - badRecipientFingerprint := filepath.Join(s.work, "bad-recipient-fingerprint.json") - s.writeJSON(badRecipientFingerprint, map[string]any{"alg": "age-hybrid-mlkem768-x25519", "kid": strings.Repeat("a", 64), "recipient": recipientIdentity.Recipient().String()}) - register(s.captain.SigningPrivate, badRecipientParse, filepath.Join(s.work, "invalid-recipient-parse.json")) - register(s.captain.SigningPrivate, badRecipientFingerprint, filepath.Join(s.work, "invalid-recipient-fingerprint.json")) - badSigningFingerprint := filepath.Join(s.work, "bad-signing-fingerprint.json") - s.writeJSON(badSigningFingerprint, map[string]any{"alg": "Ed25519", "kid": strings.Repeat("a", 64), "public": base64.RawURLEncoding.EncodeToString(make([]byte, ed25519.PublicKeySize))}) - s.failure("decverify", "--input", s.stream, "--output", filepath.Join(s.work, "invalid-signing-fingerprint.log"), "--context", s.streamBinding, "--ver-public-key", badSigningFingerprint, "--dec-private-key", s.captain.RecipientPrivate, "--passphrase-file", s.passphrase) - - recipientMetadata := filepath.Join(s.work, "recipient-metadata.age") - s.writePassphraseEncrypted(recipientMetadata, []byte(`{"kind":"wrong"}`)) - recipientParse := filepath.Join(s.work, "recipient-parse.age") - s.writePassphraseEncrypted(recipientParse, []byte(`{"kind":"recipient-private","alg":"age-hybrid-mlkem768-x25519","kid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","identity":"not-an-identity"}`)) - recipientFingerprint := filepath.Join(s.work, "recipient-fingerprint.age") - s.writePassphraseEncrypted(recipientFingerprint, []byte(`{"kind":"recipient-private","alg":"age-hybrid-mlkem768-x25519","kid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","identity":"`+recipientIdentity.String()+`"}`)) - decrypt := func(recipientPath, output string) { - s.failure("decverify", "--input", s.stream, "--output", output, "--context", s.streamBinding, "--ver-public-key", s.captain.SigningPublic, "--dec-private-key", recipientPath, "--passphrase-file", s.passphrase) - } - decrypt(invalidCiphertext, filepath.Join(s.work, "invalid-recipient-cipher.log")) - decrypt(invalidJSON, filepath.Join(s.work, "invalid-recipient-json.log")) - decrypt(recipientMetadata, filepath.Join(s.work, "invalid-recipient-metadata.log")) - decrypt(recipientParse, filepath.Join(s.work, "invalid-recipient-parse.log")) - decrypt(recipientFingerprint, filepath.Join(s.work, "invalid-recipient-fingerprint.log")) -} - -func (s *eventSuite) TestRequestWindowRejections() { - registrationEvent := filepath.Join(s.work, "registration-window-event.json") - registrationBinding := s.copyObject(s.event) - registrationBinding["ttl_seconds"].(map[string]any)["registration"] = 200000000 - s.writeJSON(registrationEvent, registrationBinding) - s.failure("identity", "register", "--event", registrationEvent, "--actor-id", "100", "--registration-id", "50000000-0000-4000-8000-000000000005", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", s.passphrase, "--output", filepath.Join(s.work, "registration-window.json")) - - submissionEvent := filepath.Join(s.work, "submission-window-event.json") - submissionBinding := s.copyObject(s.event) - submissionBinding["ttl_seconds"].(map[string]any)["submission"] = 200000000 - s.writeJSON(submissionEvent, submissionBinding) - s.failure("submission", "prepare", "--event", submissionEvent, "--input", s.bundle, "--team-id", s.teamID, "--attempt-id", "50000000-0000-4000-8000-000000000005", "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", filepath.Join(s.work, "submission-window.json")) - - teamEvent := filepath.Join(s.work, "team-window-event.json") - teamBinding := s.copyObject(s.event) - teamBinding["ttl_seconds"].(map[string]any)["team"] = 200000000 - s.writeJSON(teamEvent, teamBinding) - registration := filepath.Join(s.work, "team-window-registration.json") - s.success("identity", "register", "--event", teamEvent, "--actor-id", "100", "--registration-id", "60000000-0000-4000-8000-000000000006", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", s.passphrase, "--output", registration) - verified := filepath.Join(s.work, "team-window-identity.json") - s.success("identity", "verify", "--event", teamEvent, "--input", registration, "--expect-actor-id", "100", "--source-time", s.issuedAt(registration), "--output", verified) - registry := filepath.Join(s.work, "team-window-registry.json") - s.writeJSON(registry, map[string]any{"v": 2, "kind": "event-registry", "event": s.readObject(registration)["event"], "revision": 0, "phase": "formation_open", "enabled": true, "disabled_reason": "", "identities": []any{s.readObject(verified)}, "teams": []any{}, "attempts": []any{}}) - s.failure("team", "propose", "--event", teamEvent, "--registry", registry, "--team-id", "50000000-0000-4000-8000-000000000005", "--actor-id", "100", "--member", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", filepath.Join(s.work, "team-window.json")) -} - -func (s *eventSuite) identityCommandFailures(missingPassphrase, badSigningPrivate, badRecipientPublic string) { - output := filepath.Join(s.work, "identity-command-output.json") - s.failure("identity", "register", "--event", s.event, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", missingPassphrase, "--output", output) - s.failure("identity", "register", "--event", s.event, "--actor-id", "100", "--sig-private-key", filepath.Join(s.work, "missing-signing.private.age"), "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", s.passphrase, "--output", output) - s.failure("identity", "register", "--event", s.event, "--actor-id", "100", "--sig-private-key", badSigningPrivate, "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", s.passphrase, "--output", output) - s.failure("identity", "register", "--event", s.event, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", filepath.Join(s.work, "missing-recipient.public.json"), "--passphrase-file", s.passphrase, "--output", output) - s.failure("identity", "register", "--event", s.event, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", badRecipientPublic, "--passphrase-file", s.passphrase, "--output", output) - s.failure("identity", "register", "--event", s.event, "--actor-id", "0", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", s.passphrase, "--output", output) - s.failure("identity", "register", "--event", s.event, "--actor-id", "100", "--key-epoch", "0", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", s.passphrase, "--output", output) - s.failure("identity", "register", "--event", s.event, "--actor-id", "100", "--registration-id", "not-a-uuid", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", s.passphrase, "--output", output) - - generated := filepath.Join(s.work, "generated-registration.json") - s.success("identity", "register", "--event", s.event, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", s.passphrase, "--output", generated) - s.failure("identity", "register", "--event", s.event, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", s.passphrase, "--output", generated) - - s.failure("identity", "verify", "--event", filepath.Join(s.work, "missing-event.json"), "--input", s.captainReg, "--expect-actor-id", "100", "--source-time", s.issuedAt(s.captainReg), "--output", output) - s.failure("identity", "verify", "--event", s.event, "--input", filepath.Join(s.work, "missing-registration.json"), "--expect-actor-id", "100", "--source-time", s.issuedAt(s.captainReg), "--output", output) - s.Require().NoError(os.WriteFile(output, []byte("already exists"), 0o600)) - s.failure("identity", "verify", "--event", s.event, "--input", s.captainReg, "--expect-actor-id", "100", "--source-time", s.issuedAt(s.captainReg), "--output", output) -} - -func (s *eventSuite) teamCommandFailures(missingPassphrase, badSigningPrivate string) { - output := filepath.Join(s.work, "team-command-output.json") - s.failure("team", "propose", "--event", filepath.Join(s.work, "missing-event.json"), "--registry", s.registry, "--actor-id", "100", "--member", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", output) - s.failure("team", "propose", "--event", s.event, "--registry", filepath.Join(s.work, "missing-registry.json"), "--actor-id", "100", "--member", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", output) - s.failure("team", "propose", "--event", s.event, "--registry", s.registry, "--actor-id", "100", "--member", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", missingPassphrase, "--output", output) - s.failure("team", "propose", "--event", s.event, "--registry", s.registry, "--actor-id", "100", "--member", "100", "--sig-private-key", badSigningPrivate, "--passphrase-file", s.passphrase, "--output", output) - - s.failure("team", "consent", "--event", filepath.Join(s.work, "missing-event.json"), "--registry", s.registry, "--proposal", s.proposal, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", output) - s.failure("team", "consent", "--event", s.event, "--registry", filepath.Join(s.work, "missing-registry.json"), "--proposal", s.proposal, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", output) - s.failure("team", "consent", "--event", s.event, "--registry", s.registry, "--proposal", filepath.Join(s.work, "missing-proposal.json"), "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", output) - s.failure("team", "consent", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", missingPassphrase, "--output", output) - s.failure("team", "consent", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--actor-id", "100", "--sig-private-key", badSigningPrivate, "--passphrase-file", s.passphrase, "--output", output) - - s.failure("team", "verify", "--event", filepath.Join(s.work, "missing-event.json"), "--registry", s.registry, "--proposal", s.proposal, "--proposal-source-time", s.issuedAt(s.proposal), "--consent", s.captainConsent, "--output", output) - s.failure("team", "verify", "--event", s.event, "--registry", filepath.Join(s.work, "missing-registry.json"), "--proposal", s.proposal, "--proposal-source-time", s.issuedAt(s.proposal), "--consent", s.captainConsent, "--output", output) - s.failure("team", "verify", "--event", s.event, "--registry", s.registry, "--proposal", filepath.Join(s.work, "missing-proposal.json"), "--proposal-source-time", s.issuedAt(s.proposal), "--consent", s.captainConsent, "--output", output) - s.failure("team", "verify", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--proposal-source-time", s.issuedAt(s.proposal), "--output", output) - s.failure("team", "verify", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--proposal-source-time", s.issuedAt(s.proposal), "--consent", filepath.Join(s.work, "missing-consent.json"), "--output", output) - s.Require().NoError(os.WriteFile(output, []byte("already exists"), 0o600)) - s.failure("team", "verify", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--proposal-source-time", s.issuedAt(s.proposal), "--consent", s.captainConsent, "--consent", s.teammateConsent, "--consent-source-time", "100="+s.issuedAt(s.captainConsent), "--consent-source-time", "200="+s.issuedAt(s.teammateConsent), "--output", output) -} - -func (s *eventSuite) submissionCommandFailures(missingPassphrase, badSigningPrivate string) { - output := filepath.Join(s.work, "submission-command-output.json") - s.failure("submission", "prepare", "--event", filepath.Join(s.work, "missing-event.json"), "--input", s.bundle, "--team-id", s.teamID, "--attempt-id", s.attemptID, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", output) - s.failure("submission", "prepare", "--event", s.event, "--input", filepath.Join(s.work, "missing-bundle"), "--team-id", s.teamID, "--attempt-id", s.attemptID, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", output) - s.failure("submission", "prepare", "--event", s.event, "--input", s.bundle, "--metadata", filepath.Join(s.work, "missing-metadata"), "--team-id", s.teamID, "--attempt-id", s.attemptID, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", output) - s.failure("submission", "prepare", "--event", s.event, "--input", s.bundle, "--team-id", s.teamID, "--attempt-id", s.attemptID, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", missingPassphrase, "--output", output) - s.failure("submission", "prepare", "--event", s.event, "--input", s.bundle, "--team-id", s.teamID, "--attempt-id", s.attemptID, "--actor-id", "100", "--sig-private-key", badSigningPrivate, "--passphrase-file", s.passphrase, "--output", output) - - s.failure("submission", "verify", "--event", filepath.Join(s.work, "missing-event.json"), "--registry", s.registry, "--request", s.submission, "--bundle", s.bundle, "--expect-actor-id", "100", "--source-time", s.issuedAt(s.submission), "--output", output) - s.failure("submission", "verify", "--event", s.event, "--registry", filepath.Join(s.work, "missing-registry.json"), "--request", s.submission, "--bundle", s.bundle, "--expect-actor-id", "100", "--source-time", s.issuedAt(s.submission), "--output", output) - s.failure("submission", "verify", "--event", s.event, "--registry", s.submissionRegistry, "--request", filepath.Join(s.work, "missing-submission.json"), "--bundle", s.bundle, "--expect-actor-id", "100", "--source-time", s.issuedAt(s.submission), "--output", output) - s.failure("submission", "verify", "--event", s.event, "--registry", s.submissionRegistry, "--request", s.submission, "--bundle", filepath.Join(s.work, "missing-bundle"), "--expect-actor-id", "100", "--source-time", s.issuedAt(s.submission), "--output", output) - s.failure("submission", "verify", "--event", s.event, "--registry", s.submissionRegistry, "--request", s.submission, "--bundle", s.bundle, "--metadata", filepath.Join(s.work, "missing-metadata"), "--expect-actor-id", "100", "--source-time", s.issuedAt(s.submission), "--output", output) - s.Require().NoError(os.WriteFile(output, []byte("already exists"), 0o600)) - s.failure("submission", "verify", "--event", s.event, "--registry", s.submissionRegistry, "--request", s.submission, "--bundle", s.bundle, "--metadata", s.metadata, "--expect-actor-id", "100", "--source-time", s.issuedAt(s.submission), "--output", output) -} - -func (s *eventSuite) TestIdentityRegistrationAndVerification() { - verified := filepath.Join(s.work, "captain-verified.json") - s.success("identity", "verify", "--event", s.event, "--input", s.captainReg, "--expect-actor-id", "100", "--source-time", s.issuedAt(s.captainReg), "--output", verified) - s.failure("identity", "verify", "--event", s.event, "--input", s.captainReg, "--expect-actor-id", "200", "--source-time", s.issuedAt(s.captainReg), "--output", filepath.Join(s.work, "wrong-actor.json")) - s.failure("identity", "verify", "--event", s.event, "--input", s.captainReg, "--expect-actor-id", "100", "--source-time", "2031-01-01T00:00:00Z", "--output", filepath.Join(s.work, "expired.json")) - bad := filepath.Join(s.work, "bad-registration.json") - data, err := os.ReadFile(s.captainReg) - s.Require().NoError(err) - data = bytes.Replace(data, []byte(`"actor_id":"100"`), []byte(`"actor_id":"999"`), 1) - s.Require().NoError(os.WriteFile(bad, data, 0o600)) - s.failure("identity", "verify", "--event", s.event, "--input", bad, "--expect-actor-id", "999", "--source-time", s.issuedAt(s.captainReg), "--output", filepath.Join(s.work, "bad-signature.json")) -} - -func (s *eventSuite) TestIdentityAndRegistryRejections() { - identityCases := map[string]func(map[string]any){ - "identity": func(value map[string]any) { - value["v"] = 1 - }, - "invalid-reference": func(value map[string]any) { - value["event"].(map[string]any)["event_id"] = "A" - }, - "window": func(value map[string]any) { - value["expires_at"] = value["issued_at"] - }, - "issued-at": func(value map[string]any) { - value["issued_at"] = "not-a-time" - }, - "signing-key": func(value map[string]any) { - value["signing_key"].(map[string]any)["alg"] = "wrong" - }, - "recipient-key": func(value map[string]any) { - value["recipient_key"].(map[string]any)["alg"] = "wrong" - }, - "signature-metadata": func(value map[string]any) { - value["signature"].(map[string]any)["alg"] = "wrong" - }, - "signature-encoding": func(value map[string]any) { - value["signature"].(map[string]any)["value"] = "!" - }, - "signature": func(value map[string]any) { - value["actor_id"] = "999" - }, + info, err := os.Stat(file.path) + s.Require().NoError(err) + s.Equal(file.mode, info.Mode().Perm()) + } + s.Contains(string(s.read(s.alice.SignaturePrivate)), "BEGIN PRIVATE KEY") + s.Contains(string(s.read(s.alice.EncryptionPrivate)), "AGE-SECRET-KEY") + s.failure("key-gen", "--out", filepath.Join(s.work, "alice")) + s.failure("key-gen") + blocked := s.write("blocked", "not a directory") + s.failure("key-gen", "--out", blocked) +} + +func (s *eventSuite) TestSoloTeamActivatesFromFormationOnly() { + formation, result := s.form("solo.tar", "Solo & Politics", "100", s.alice, "100") + s.NotEmpty(result.TeamID) + s.Empty(result.AttemptID) + s.Equal(0, result.FileCount) + entries := s.tarEntries(formation) + s.Len(entries, 1) + member := s.manifest(formation) + s.Equal("eventctl/v3", member.Protocol) + s.Equal("team-formation", member.Kind) + s.Equal("pythonhk/event-smoke", member.EventID) + s.Equal("100", member.GitHubID) + s.Equal(result.TeamID, member.TeamID) + s.Equal("Solo & Politics", member.TeamName) + s.Equal([]string{"100"}, member.Members) + s.NotEmpty(member.SigningPublicKey) + s.NotEmpty(member.EncryptionPublicKey) + s.NotEmpty(member.Signature) + + s.failure("team", "key", "--formation", formation, "--github-id", "100", "--sig-priv-key", s.alice.SignaturePrivate, "--enc-pub-key", s.alice.EncryptionPublic, "--out", filepath.Join(s.work, "duplicate-key.tar")) + s.failure("team", "form", "--event-id", "pythonhk/event-smoke", "--team-name", "missing initiator", "--github-id", "100", "--member", "200", "--sig-priv-key", s.alice.SignaturePrivate, "--enc-pub-key", s.alice.EncryptionPublic, "--out", filepath.Join(s.work, "missing-initiator.tar")) + s.failure("team", "form", "--event-id", "pythonhk/event-smoke", "--team-name", "duplicate", "--github-id", "100", "--member", "100", "--member", "100", "--sig-priv-key", s.alice.SignaturePrivate, "--enc-pub-key", s.alice.EncryptionPublic, "--out", filepath.Join(s.work, "duplicate-member.tar")) + s.failure("team", "form", "--event-id", "bad", "--team-name", "bad event", "--github-id", "100", "--member", "100", "--sig-priv-key", s.alice.SignaturePrivate, "--enc-pub-key", s.alice.EncryptionPublic, "--out", filepath.Join(s.work, "bad-event.tar")) + s.failure("team", "form", "--event-id", "pythonhk/event-smoke", "--team-name", " ", "--github-id", "100", "--member", "100", "--sig-priv-key", s.alice.SignaturePrivate, "--enc-pub-key", s.alice.EncryptionPublic, "--out", filepath.Join(s.work, "empty-name.tar")) +} + +func (s *eventSuite) TestMultiMemberKeyProofAndTampering() { + formation, result := s.form("multi.tar", "The Peers", "100", s.alice, "200", "100") + base := s.manifest(formation) + s.Equal([]string{"100", "200"}, base.Members) + + key := filepath.Join(s.work, "bob-key.tar") + response := s.success("team", "key", "--formation", formation, "--github-id", "200", "--sig-priv-key", s.bob.SignaturePrivate, "--enc-pub-key", s.bob.EncryptionPublic, "--out", key) + var keyResult artifactResult + s.Require().NoError(json.Unmarshal(response.Result, &keyResult)) + s.Equal(result.TeamID, keyResult.TeamID) + proof := s.manifest(key) + s.Equal("team-key", proof.Kind) + s.Equal("200", proof.GitHubID) + s.Equal(result.TeamID, proof.TeamID) + s.Equal(result.SHA256, proof.FormationSHA256) + s.NotEmpty(proof.EncryptionPublicKey) + + s.failure("team", "key", "--formation", formation, "--github-id", "300", "--sig-priv-key", s.outsider.SignaturePrivate, "--enc-pub-key", s.outsider.EncryptionPublic, "--out", filepath.Join(s.work, "outsider-key.tar")) + s.failure("team", "key", "--formation", filepath.Join(s.work, "missing.tar"), "--github-id", "200", "--sig-priv-key", s.bob.SignaturePrivate, "--enc-pub-key", s.bob.EncryptionPublic, "--out", filepath.Join(s.work, "missing-key.tar")) + + entries := s.tarEntries(formation) + var changed map[string]any + s.Require().NoError(json.Unmarshal(entries["manifest.json"], &changed)) + changed["team_name"] = "Changed after signing" + entries["manifest.json"], _ = json.Marshal(changed) + tampered := filepath.Join(s.work, "tampered-formation.tar") + s.writeTar(tampered, entries) + s.failure("team", "key", "--formation", tampered, "--github-id", "200", "--sig-priv-key", s.bob.SignaturePrivate, "--enc-pub-key", s.bob.EncryptionPublic, "--out", filepath.Join(s.work, "tampered-key.tar")) + + invalid := s.write("invalid-formation.tar", "not a tar") + s.failure("team", "key", "--formation", invalid, "--github-id", "200", "--sig-priv-key", s.bob.SignaturePrivate, "--enc-pub-key", s.bob.EncryptionPublic, "--out", filepath.Join(s.work, "invalid-key.tar")) +} + +func (s *eventSuite) TestSubmissionPreparation() { + _, team := s.form("submission-team.tar", "Submitters", "100", s.alice, "100") + second := s.write("evidence.txt", "evidence\n") + output := filepath.Join(s.work, "submission.tar") + response := s.success("submission", "prepare", "--event-id", "pythonhk/event-smoke", "--github-id", "100", "--team-id", team.TeamID, "--input", second, "--input", s.input, "--sig-priv-key", s.alice.SignaturePrivate, "--out", output) + var result artifactResult + s.Require().NoError(json.Unmarshal(response.Result, &result)) + s.Equal(team.TeamID, result.TeamID) + s.NotEmpty(result.AttemptID) + s.Equal(2, result.FileCount) + manifest := s.manifest(output) + s.Equal("submission", manifest.Kind) + s.Equal(result.AttemptID, manifest.AttemptID) + s.Equal([]string{"files/evidence.txt", "files/exploit.whl"}, []string{manifest.Files[0].Path, manifest.Files[1].Path}) + firstDigest := result.SHA256 + s.Require().NoError(os.WriteFile(s.input, []byte("revised wheel payload\n"), 0o600)) + response = s.success("submission", "prepare", "--event-id", "pythonhk/event-smoke", "--github-id", "100", "--team-id", team.TeamID, "--input", s.input, "--sig-priv-key", s.alice.SignaturePrivate, "--out", output) + s.Require().NoError(json.Unmarshal(response.Result, &result)) + s.NotEqual(firstDigest, result.SHA256) + + s.failure("submission", "prepare", "--event-id", "pythonhk/event-smoke", "--github-id", "100", "--team-id", team.TeamID, "--sig-priv-key", s.alice.SignaturePrivate, "--out", filepath.Join(s.work, "no-input.tar")) + s.failure("submission", "prepare", "--event-id", "pythonhk/event-smoke", "--github-id", "no", "--team-id", team.TeamID, "--input", s.input, "--sig-priv-key", s.alice.SignaturePrivate, "--out", filepath.Join(s.work, "bad-actor.tar")) + s.failure("submission", "prepare", "--event-id", "pythonhk/event-smoke", "--github-id", "100", "--team-id", "not-a-uuid", "--input", s.input, "--sig-priv-key", s.alice.SignaturePrivate, "--out", filepath.Join(s.work, "bad-team.tar")) + badKey := s.write("bad-signing-key.pem", "not a key") + s.failure("submission", "prepare", "--event-id", "pythonhk/event-smoke", "--github-id", "100", "--team-id", team.TeamID, "--input", s.input, "--sig-priv-key", badKey, "--out", filepath.Join(s.work, "bad-key.tar")) + s.failure("submission", "prepare", "--event-id", "pythonhk/event-smoke", "--github-id", "100", "--team-id", team.TeamID, "--input", s.work, "--sig-priv-key", s.alice.SignaturePrivate, "--out", filepath.Join(s.work, "directory-input.tar")) +} + +func (s *eventSuite) TestEncryptedFeedbackForEveryMember() { + secondLog := s.write("details.log", "more private output\n") + feedback := filepath.Join(s.work, "feedback.tar") + response := s.success("sigcrypt", "--input", s.log, "--input", secondLog, "--sig-priv-key", s.alice.SignaturePrivate, "--enc-pub-key", s.alice.EncryptionPublic, "--enc-pub-key", s.bob.EncryptionPublic, "--enc-pub-key", s.alice.EncryptionPublic, "--out", feedback) + var result artifactResult + s.Require().NoError(json.Unmarshal(response.Result, &result)) + s.Equal(2, result.FileCount) + s.Equal(2, result.RecipientCount) + entries := s.tarEntries(feedback) + s.ElementsMatch([]string{"manifest.json", "payload.age"}, sortedKeys(entries)) + s.Equal("feedback", s.manifest(feedback).Kind) + + for name, keys := range map[string]keyPaths{"alice": s.alice, "bob": s.bob} { + output := filepath.Join(s.work, name+"-feedback") + s.success("decverify", "--input", feedback, "--out-dir", output, "--sig-pub-key", s.alice.SignaturePublic, "--enc-priv-key", keys.EncryptionPrivate) + s.Equal("private judge output\n", string(s.read(filepath.Join(output, "judge.log")))) + s.Equal("more private output\n", string(s.read(filepath.Join(output, "details.log")))) + } + s.failure("decverify", "--input", feedback, "--out-dir", filepath.Join(s.work, "outsider-feedback"), "--sig-pub-key", s.alice.SignaturePublic, "--enc-priv-key", s.outsider.EncryptionPrivate) + s.failure("decverify", "--input", feedback, "--out-dir", filepath.Join(s.work, "wrong-signer"), "--sig-pub-key", s.bob.SignaturePublic, "--enc-priv-key", s.alice.EncryptionPrivate) + nonempty := filepath.Join(s.work, "nonempty") + s.Require().NoError(os.MkdirAll(nonempty, 0o700)) + s.write(filepath.Join("nonempty", "already.txt"), "present") + s.failure("decverify", "--input", feedback, "--out-dir", nonempty, "--sig-pub-key", s.alice.SignaturePublic, "--enc-priv-key", s.alice.EncryptionPrivate) + + s.failure("sigcrypt", "--input", s.log, "--sig-priv-key", s.alice.SignaturePrivate, "--out", filepath.Join(s.work, "no-recipient.tar")) + s.failure("sigcrypt", "--sig-priv-key", s.alice.SignaturePrivate, "--enc-pub-key", s.alice.EncryptionPublic, "--out", filepath.Join(s.work, "no-input.tar")) + badRecipient := s.write("bad-recipient.pub", "not an age recipient") + s.failure("sigcrypt", "--input", s.log, "--sig-priv-key", s.alice.SignaturePrivate, "--enc-pub-key", badRecipient, "--out", filepath.Join(s.work, "bad-recipient.tar")) + + tampered := s.tarEntries(feedback) + var changed map[string]any + s.Require().NoError(json.Unmarshal(tampered["manifest.json"], &changed)) + changed["signature"] = "bad" + tampered["manifest.json"], _ = json.Marshal(changed) + tamperedFeedback := filepath.Join(s.work, "tampered-feedback.tar") + s.writeTar(tamperedFeedback, tampered) + s.failure("decverify", "--input", tamperedFeedback, "--out-dir", filepath.Join(s.work, "tampered-output"), "--sig-pub-key", s.alice.SignaturePublic, "--enc-priv-key", s.alice.EncryptionPrivate) +} + +func (s *eventSuite) TestCommandAndKeyFailurePaths() { + formation, team := s.form("command-formation.tar", "Command Paths", "100", s.alice, "100", "200") + feedback := s.signedFeedback("command-feedback.tar", s.innerTar(rawTarEntry{Name: "files/result.log", Data: []byte("result\n")})) + missing := filepath.Join(s.work, "missing-key") + badPrivate := s.write("bad-private.pem", "not a PEM") + badPrivateDER := s.pem("bad-private-der.pem", "PRIVATE KEY", []byte("not PKCS8")) + otherPrivate := s.ecdsaPrivate("other-private.pem") + badPublic := s.write("bad-public.pem", "not a PEM") + badPublicDER := s.pem("bad-public-der.pem", "PUBLIC KEY", []byte("not PKIX")) + otherPublic := s.ecdsaPublic("other-public.pem") + badRecipient := s.write("bad-recipient-for-command.pub", "not an age recipient") + badIdentity := s.write("bad-identity.key", "not an age identity") + + s.failure("team", "form", "--event-id", "pythonhk/event-smoke", "--team-name", "missing signing", "--github-id", "100", "--member", "100", "--sig-priv-key", missing, "--enc-pub-key", s.alice.EncryptionPublic, "--out", filepath.Join(s.work, "missing-signing.tar")) + s.failure("team", "form", "--event-id", "pythonhk/event-smoke", "--team-name", "missing recipient", "--github-id", "100", "--member", "100", "--sig-priv-key", s.alice.SignaturePrivate, "--enc-pub-key", missing, "--out", filepath.Join(s.work, "missing-recipient.tar")) + s.failure("team", "form", "--event-id", "pythonhk/event-smoke", "--team-name", "bad recipient", "--github-id", "100", "--member", "100", "--sig-priv-key", s.alice.SignaturePrivate, "--enc-pub-key", badRecipient, "--out", filepath.Join(s.work, "bad-recipient.tar")) + s.failure("team", "form", "--event-id", "pythonhk/event-smoke", "--team-name", "directory output", "--github-id", "100", "--member", "100", "--sig-priv-key", s.alice.SignaturePrivate, "--enc-pub-key", s.alice.EncryptionPublic, "--out", s.work) + blockedOutput := s.write("output-obstacle", "not a directory") + s.failure("team", "form", "--event-id", "pythonhk/event-smoke", "--team-name", "blocked output", "--github-id", "100", "--member", "100", "--sig-priv-key", s.alice.SignaturePrivate, "--enc-pub-key", s.alice.EncryptionPublic, "--out", filepath.Join(blockedOutput, "formation.tar")) + + s.failure("team", "key", "--formation", formation, "--github-id", "200", "--sig-priv-key", missing, "--enc-pub-key", s.bob.EncryptionPublic, "--out", filepath.Join(s.work, "missing-team-key.tar")) + s.failure("team", "key", "--formation", formation, "--github-id", "200", "--sig-priv-key", s.bob.SignaturePrivate, "--enc-pub-key", badRecipient, "--out", filepath.Join(s.work, "bad-team-key.tar")) + s.failure("team", "key", "--formation", formation, "--github-id", "200", "--sig-priv-key", s.bob.SignaturePrivate, "--enc-pub-key", s.bob.EncryptionPublic, "--out", s.work) + + for index, private := range []string{badPrivate, badPrivateDER, otherPrivate} { + s.failure("submission", "prepare", "--event-id", "pythonhk/event-smoke", "--github-id", "100", "--team-id", team.TeamID, "--input", s.input, "--sig-priv-key", private, "--out", filepath.Join(s.work, "bad-private-"+string(rune('a'+index))+".tar")) + } + s.failure("sigcrypt", "--input", s.log, "--sig-priv-key", badPrivate, "--enc-pub-key", s.alice.EncryptionPublic, "--out", filepath.Join(s.work, "bad-sigcrypt-private.tar")) + s.failure("sigcrypt", "--input", s.log, "--sig-priv-key", s.alice.SignaturePrivate, "--enc-pub-key", s.alice.EncryptionPublic, "--out", s.work) + + for index, public := range []string{missing, badPublic, badPublicDER, otherPublic} { + s.failure("decverify", "--input", feedback, "--out-dir", filepath.Join(s.work, "bad-public-output-"+string(rune('a'+index))), "--sig-pub-key", public, "--enc-priv-key", s.alice.EncryptionPrivate) + } + s.failure("decverify", "--input", feedback, "--out-dir", filepath.Join(s.work, "missing-identity-output"), "--sig-pub-key", s.alice.SignaturePublic, "--enc-priv-key", missing) + s.failure("decverify", "--input", feedback, "--out-dir", filepath.Join(s.work, "bad-identity-output"), "--sig-pub-key", s.alice.SignaturePublic, "--enc-priv-key", badIdentity) + s.failure("decverify", "--input", missing, "--out-dir", filepath.Join(s.work, "missing-feedback-output"), "--sig-pub-key", s.alice.SignaturePublic, "--enc-priv-key", s.alice.EncryptionPrivate) + + large := s.write("large-input.bin", "") + s.Require().NoError(os.Truncate(large, 64<<20+1)) + s.failure("submission", "prepare", "--event-id", "pythonhk/event-smoke", "--github-id", "100", "--team-id", team.TeamID, "--input", large, "--sig-priv-key", s.alice.SignaturePrivate, "--out", filepath.Join(s.work, "large-input.tar")) + duplicateOne := s.write(filepath.Join("one", "same.txt"), "one") + duplicateTwo := s.write(filepath.Join("two", "same.txt"), "two") + s.failure("submission", "prepare", "--event-id", "pythonhk/event-smoke", "--github-id", "100", "--team-id", team.TeamID, "--input", duplicateOne, "--input", duplicateTwo, "--sig-priv-key", s.alice.SignaturePrivate, "--out", filepath.Join(s.work, "duplicate-input.tar")) + backslash := s.write("unsafe\\name.txt", "unsafe") + s.failure("submission", "prepare", "--event-id", "pythonhk/event-smoke", "--github-id", "100", "--team-id", team.TeamID, "--input", backslash, "--sig-priv-key", s.alice.SignaturePrivate, "--out", filepath.Join(s.work, "unsafe-input.tar")) + s.failure("sigcrypt", "--input", backslash, "--sig-priv-key", s.alice.SignaturePrivate, "--enc-pub-key", s.alice.EncryptionPublic, "--out", filepath.Join(s.work, "unsafe-feedback.tar")) + + s.failure("team", "form", "--event-id", "pythonhk/event-smoke", "--team-name", "missing member", "--github-id", "100", "--sig-priv-key", s.alice.SignaturePrivate, "--enc-pub-key", s.alice.EncryptionPublic, "--out", filepath.Join(s.work, "no-member.tar")) + s.failure("team", "form", "--event-id", "pythonhk/event-smoke", "--team-name", "bad member", "--github-id", "100", "--member", "0", "--sig-priv-key", s.alice.SignaturePrivate, "--enc-pub-key", s.alice.EncryptionPublic, "--out", filepath.Join(s.work, "bad-member.tar")) + s.failure("team", "form", "--event-id", "pythonhk/event-smoke", "--team-name", "line\nbreak", "--github-id", "100", "--member", "100", "--sig-priv-key", s.alice.SignaturePrivate, "--enc-pub-key", s.alice.EncryptionPublic, "--out", filepath.Join(s.work, "control-name.tar")) + s.success("team", "form", "--event-id", "pythonhk/event-smoke", "--team-name", "different lengths", "--github-id", "100", "--member", "100", "--member", "9", "--sig-priv-key", s.alice.SignaturePrivate, "--enc-pub-key", s.alice.EncryptionPublic, "--out", filepath.Join(s.work, "sorted-members.tar")) +} + +func (s *eventSuite) TestArtifactReaderRejectsMalformedRequests() { + formation, team := s.form("reader-formation.tar", "Reader Paths", "100", s.alice, "100", "200") + key := filepath.Join(s.work, "reader-key.tar") + s.success("team", "key", "--formation", formation, "--github-id", "200", "--sig-priv-key", s.bob.SignaturePrivate, "--enc-pub-key", s.bob.EncryptionPublic, "--out", key) + submission := filepath.Join(s.work, "reader-submission.tar") + s.success("submission", "prepare", "--event-id", "pythonhk/event-smoke", "--github-id", "100", "--team-id", team.TeamID, "--input", s.input, "--sig-priv-key", s.alice.SignaturePrivate, "--out", submission) + feedback := s.signedFeedback("reader-feedback.tar", s.innerTar(rawTarEntry{Name: "files/result.log", Data: []byte("result\n")})) + + fail := func(file string) { + s.failure("team", "key", "--formation", file, "--github-id", "200", "--sig-priv-key", s.bob.SignaturePrivate, "--enc-pub-key", s.bob.EncryptionPublic, "--out", filepath.Join(s.work, "rejected-"+filepath.Base(file))) + } + fail(s.writeRawTar("no-manifest.tar", rawTarEntry{Name: "note.txt", Data: []byte("missing")})) + fail(s.write("malformed-header.tar", strings.Repeat("x", 512))) + fail(s.writeRawTar("unsafe-name.tar", rawTarEntry{Name: "../outside", Data: []byte("unsafe")})) + fail(s.writeRawTar("directory-entry.tar", rawTarEntry{Name: "folder", Type: tar.TypeDir})) + fail(s.writeRawTar("duplicate-entry.tar", rawTarEntry{Name: "manifest.json", Data: []byte("{}")}, rawTarEntry{Name: "manifest.json", Data: []byte("{}")})) + fail(s.writeTruncatedTar("truncated-entry.tar")) + fail(s.writeOversizedHeaderTar("oversized-entry.tar")) + + fail(s.replaceManifest("invalid-json.tar", formation, []byte("{"))) + fail(s.mutateManifest("unknown-field.tar", formation, func(value map[string]any) { value["unexpected"] = true })) + fail(s.replaceManifest("trailing-json.tar", formation, append(s.tarEntries(formation)["manifest.json"], []byte("{}")...))) + fail(s.mutateManifest("wrong-protocol.tar", formation, func(value map[string]any) { value["protocol"] = "eventctl/v2" })) + fail(s.mutateManifest("invalid-public-key.tar", formation, func(value map[string]any) { value["signing_public_key"] = "not a key" })) + fail(s.mutateManifest("unknown-kind.tar", formation, func(value map[string]any) { value["kind"] = "unknown" })) + fail(s.mutateManifest("unsorted-members.tar", formation, func(value map[string]any) { value["members"] = []string{"100", "9"} })) + fail(s.mutateManifest("bad-formation-encryption.tar", formation, func(value map[string]any) { value["encryption_public_key"] = "not age" })) + fail(s.mutateManifest("bad-formation-fields.tar", formation, func(value map[string]any) { value["formation_sha256"] = strings.Repeat("a", 64) })) + fail(s.mutateManifest("bad-key-fields.tar", key, func(value map[string]any) { value["formation_sha256"] = "bad" })) + fail(s.mutateManifest("bad-key-identity.tar", key, func(value map[string]any) { value["team_id"] = "bad" })) + fail(s.mutateManifest("bad-key-encryption.tar", key, func(value map[string]any) { value["encryption_public_key"] = "not age" })) + fail(s.mutateManifest("bad-submission-identity.tar", submission, func(value map[string]any) { value["attempt_id"] = "bad" })) + fail(s.mutateManifest("bad-submission-files.tar", submission, func(value map[string]any) { + value["files"] = []any{map[string]any{"path": "other", "sha256": strings.Repeat("a", 64), "size": 1}} + })) + fail(s.mutateManifest("bad-feedback-identity.tar", feedback, func(value map[string]any) { value["event_id"] = "pythonhk/event-smoke" })) + fail(s.mutateManifest("bad-feedback-files.tar", feedback, func(value map[string]any) { value["files"] = []any{} })) + + entries := s.tarEntries(submission) + entries["files/extra.txt"] = []byte("extra") + s.writeTar(filepath.Join(s.work, "submission-extra-entry.tar"), entries) + fail(filepath.Join(s.work, "submission-extra-entry.tar")) + entries = s.tarEntries(submission) + for name := range entries { + if strings.HasPrefix(name, "files/") { + delete(entries, name) + break + } } - for name, mutate := range identityCases { - path := filepath.Join(s.work, "rejected-identity-"+name+".json") - value := s.copyObject(s.captainReg) - mutate(value) - s.writeJSON(path, value) - actorID := "100" - if name == "signature" { - actorID = "999" + entries["files/replacement.txt"] = []byte("replacement") + s.writeTar(filepath.Join(s.work, "submission-missing-entry.tar"), entries) + fail(filepath.Join(s.work, "submission-missing-entry.tar")) + entries = s.tarEntries(submission) + for name := range entries { + if strings.HasPrefix(name, "files/") { + entries[name] = []byte("short") } - s.failure(s.identityVerifyArgs(path, actorID, s.issuedAt(s.captainReg), filepath.Join(s.work, "rejected-identity-"+name+".verified.json"))...) } - s.failure(s.identityVerifyArgs(s.captainReg, "100", "not-a-time", filepath.Join(s.work, "rejected-identity-time.verified.json"))...) - - registryCases := map[string]func(map[string]any){ - "discriminator": func(value map[string]any) { - value["kind"] = "wrong" - }, - "invalid-reference": func(value map[string]any) { - value["event"].(map[string]any)["event_id"] = "A" - }, - "empty": func(value map[string]any) { - value["identities"] = []any{} - }, - "identity": func(value map[string]any) { - value["identities"].([]any)[0].(map[string]any)["actor_id"] = "0" - }, - "signing-key": func(value map[string]any) { - value["identities"].([]any)[0].(map[string]any)["signing_key"].(map[string]any)["alg"] = "wrong" - }, - "recipient-key": func(value map[string]any) { - value["identities"].([]any)[0].(map[string]any)["recipient_key"].(map[string]any)["recipient"] = "wrong" - }, - "sorted": func(value map[string]any) { - identities := value["identities"].([]any) - value["identities"] = []any{identities[1], identities[0]} - }, - "duplicate": func(value map[string]any) { - identities := value["identities"].([]any) - identities[1].(map[string]any)["actor_id"] = identities[0].(map[string]any)["actor_id"] - }, + s.writeTar(filepath.Join(s.work, "submission-wrong-size.tar"), entries) + fail(filepath.Join(s.work, "submission-wrong-size.tar")) + entries = s.tarEntries(submission) + for name, data := range entries { + if strings.HasPrefix(name, "files/") { + entries[name] = append([]byte(nil), data...) + entries[name][0] ^= 1 + } } - for name, mutate := range registryCases { - path := filepath.Join(s.work, "rejected-registry-"+name+".json") - value := s.copyObject(s.registry) - mutate(value) - s.writeJSON(path, value) - s.failure(s.teamProposeArgs(path, "50000000-0000-4000-8000-000000000005", "100", s.captain.SigningPrivate, filepath.Join(s.work, "rejected-registry-"+name+".proposal.json"), "100")...) + s.writeTar(filepath.Join(s.work, "submission-wrong-digest.tar"), entries) + fail(filepath.Join(s.work, "submission-wrong-digest.tar")) + entries = s.tarEntries(feedback) + entries["unexpected"] = []byte("extra") + s.writeTar(filepath.Join(s.work, "feedback-extra-entry.tar"), entries) + fail(filepath.Join(s.work, "feedback-extra-entry.tar")) + entries = s.tarEntries(feedback) + delete(entries, "payload.age") + entries["unexpected"] = []byte("missing") + s.writeTar(filepath.Join(s.work, "feedback-missing-payload.tar"), entries) + fail(filepath.Join(s.work, "feedback-missing-payload.tar")) + entries = s.tarEntries(feedback) + entries["payload.age"][0] ^= 1 + s.writeTar(filepath.Join(s.work, "feedback-wrong-digest.tar"), entries) + fail(filepath.Join(s.work, "feedback-wrong-digest.tar")) + + s.failure("team", "key", "--formation", submission, "--github-id", "200", "--sig-priv-key", s.bob.SignaturePrivate, "--enc-pub-key", s.bob.EncryptionPublic, "--out", filepath.Join(s.work, "submission-is-not-formation.tar")) +} + +func (s *eventSuite) TestSignedFeedbackPayloadFailures() { + validPayload := s.innerTar(rawTarEntry{Name: "files/result.log", Data: []byte("result\n")}) + normal := s.signedFeedback("payload-normal.tar", validPayload) + formation, _ := s.form("payload-formation.tar", "Not Feedback", "100", s.alice, "100") + s.failure("decverify", "--input", formation, "--out-dir", filepath.Join(s.work, "not-feedback"), "--sig-pub-key", s.alice.SignaturePublic, "--enc-priv-key", s.alice.EncryptionPrivate) + + tampered := s.resignFeedbackPayload("payload-late-tamper.tar", normal, func(payload []byte) { payload[len(payload)-1] ^= 1 }) + s.failure("decverify", "--input", tampered, "--out-dir", filepath.Join(s.work, "late-tamper"), "--sig-pub-key", s.alice.SignaturePublic, "--enc-priv-key", s.alice.EncryptionPrivate) + nonTar := s.signedFeedback("payload-not-tar.tar", []byte("not a tar")) + s.failure("decverify", "--input", nonTar, "--out-dir", filepath.Join(s.work, "not-tar"), "--sig-pub-key", s.alice.SignaturePublic, "--enc-priv-key", s.alice.EncryptionPrivate) + empty := s.signedFeedback("payload-empty.tar", s.innerTar()) + s.failure("decverify", "--input", empty, "--out-dir", filepath.Join(s.work, "empty-payload"), "--sig-pub-key", s.alice.SignaturePublic, "--enc-priv-key", s.alice.EncryptionPrivate) + unsafe := s.signedFeedback("payload-unsafe.tar", s.innerTar(rawTarEntry{Name: "not-files", Data: []byte("unsafe")})) + s.failure("decverify", "--input", unsafe, "--out-dir", filepath.Join(s.work, "unsafe-payload"), "--sig-pub-key", s.alice.SignaturePublic, "--enc-priv-key", s.alice.EncryptionPrivate) + nested := s.signedFeedback("payload-nested.tar", s.innerTar(rawTarEntry{Name: "files/nested/result.log", Data: []byte("nested")})) + s.failure("decverify", "--input", nested, "--out-dir", filepath.Join(s.work, "nested-payload"), "--sig-pub-key", s.alice.SignaturePublic, "--enc-priv-key", s.alice.EncryptionPrivate) + + blocked := s.write("feedback-output-obstacle", "not a directory") + s.failure("decverify", "--input", normal, "--out-dir", filepath.Join(blocked, "output"), "--sig-pub-key", s.alice.SignaturePublic, "--enc-priv-key", s.alice.EncryptionPrivate) + readonly := filepath.Join(s.work, "readonly-feedback-output") + s.Require().NoError(os.MkdirAll(readonly, 0o500)) + s.Require().NoError(os.Chmod(readonly, 0o500)) + s.failure("decverify", "--input", normal, "--out-dir", readonly, "--sig-pub-key", s.alice.SignaturePublic, "--enc-priv-key", s.alice.EncryptionPrivate) + s.Require().NoError(os.Chmod(readonly, 0o700)) +} + +func (s *eventSuite) keyGen(name string) keyPaths { + response := s.success("key-gen", "--out", filepath.Join(s.work, name)) + var result keyPaths + s.Require().NoError(json.Unmarshal(response.Result, &result)) + return result +} + +func (s *eventSuite) form(name, teamName, githubID string, keys keyPaths, members ...string) (string, artifactResult) { + output := filepath.Join(s.work, name) + arguments := []string{"team", "form", "--event-id", "pythonhk/event-smoke", "--team-name", teamName, "--github-id", githubID} + for _, member := range members { + arguments = append(arguments, "--member", member) } - - s.failure(s.teamProposeArgs(s.registry, "50000000-0000-4000-8000-000000000005", "999", s.captain.SigningPrivate, filepath.Join(s.work, "missing-proposer.proposal.json"), "999")...) - s.failure(s.teamProposeArgs(s.registry, "50000000-0000-4000-8000-000000000005", "100", s.captain.SigningPrivate, filepath.Join(s.work, "missing-member.proposal.json"), "100", "999")...) - s.failure(s.teamProposeArgs(s.registry, "not-a-uuid", "100", s.captain.SigningPrivate, filepath.Join(s.work, "bad-id.proposal.json"), "100")...) - s.failure(s.teamProposeArgs(s.registry, "50000000-0000-4000-8000-000000000005", "100", s.captain.SigningPrivate, filepath.Join(s.work, "captain-not-member.proposal.json"), "200")...) - - generated := filepath.Join(s.work, "generated-team.proposal.json") - s.success(s.teamProposeArgs(s.registry, "", "100", s.captain.SigningPrivate, generated, "100", "200")...) - s.failure(s.teamProposeArgs(s.registry, "50000000-0000-4000-8000-000000000005", "100", s.captain.SigningPrivate, generated, "100")...) + arguments = append(arguments, "--sig-priv-key", keys.SignaturePrivate, "--enc-pub-key", keys.EncryptionPublic, "--out", output) + response := s.success(arguments...) + var result artifactResult + s.Require().NoError(json.Unmarshal(response.Result, &result)) + return output, result } -func (s *eventSuite) TestRegistryBoundTeamLifecycle() { - verified := filepath.Join(s.work, "team-verified.json") - s.success("team", "verify", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--proposal-source-time", s.issuedAt(s.proposal), "--consent", s.captainConsent, "--consent", s.teammateConsent, "--consent-source-time", "100="+s.issuedAt(s.captainConsent), "--consent-source-time", "200="+s.issuedAt(s.teammateConsent), "--output", verified) - s.failure("team", "verify", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--proposal-source-time", s.issuedAt(s.proposal), "--consent", s.captainConsent, "--consent-source-time", "100="+s.issuedAt(s.captainConsent), "--output", filepath.Join(s.work, "missing-consent.json")) - s.failure("team", "propose", "--event", s.event, "--registry", s.registry, "--team-id", "50000000-0000-4000-8000-000000000005", "--actor-id", "100", "--member", "100", "--sig-private-key", s.outsider.SigningPrivate, "--passphrase-file", s.passphrase, "--output", filepath.Join(s.work, "wrong-key-proposal.json")) - s.failure("team", "consent", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--actor-id", "200", "--sig-private-key", s.outsider.SigningPrivate, "--passphrase-file", s.passphrase, "--output", filepath.Join(s.work, "wrong-key-consent.json")) +func (s *eventSuite) success(arguments ...string) cliResponse { + code, output := s.raw(-1, arguments...) + s.Equal(0, code, string(output)) + var response cliResponse + s.Require().NoError(json.Unmarshal(output, &response), string(output)) + s.True(response.OK, response.Error) + return response } -func (s *eventSuite) TestRegistryStateRejections() { - registryCases := map[string]func(map[string]any){ - "phase": func(value map[string]any) { - value["phase"] = "wrong" - }, - "enabled-reason": func(value map[string]any) { - value["disabled_reason"] = "wrong" - }, - "disabled-reason": func(value map[string]any) { - value["enabled"] = false - }, - "team": func(value map[string]any) { - value["teams"].([]any)[0].(map[string]any)["team_id"] = "wrong" - }, - "teams-sorted": func(value map[string]any) { - team := value["teams"].([]any)[0] - value["teams"] = []any{team, team} - }, - "team-member": func(value map[string]any) { - value["teams"].([]any)[0].(map[string]any)["members"].([]any)[0].(map[string]any)["actor_id"] = "999" - }, - "team-members-sorted": func(value map[string]any) { - members := value["teams"].([]any)[0].(map[string]any)["members"].([]any) - value["teams"].([]any)[0].(map[string]any)["members"] = []any{members[1], members[0]} - }, - "team-member-reused": func(value map[string]any) { - team := value["teams"].([]any)[0].(map[string]any) - duplicate := s.copyValue(team) - duplicate["team_id"] = "50000000-0000-4000-8000-000000000005" - value["teams"] = []any{team, duplicate} - }, - "attempt": func(value map[string]any) { - value["attempts"] = []any{map[string]any{"attempt_id": "wrong"}} - }, - "attempts-sorted": func(value map[string]any) { - attempt := s.attemptRecord("40000000-0000-4000-8000-000000000004") - value["attempts"] = []any{attempt, attempt} - }, - "attempt-member": func(value map[string]any) { - attempt := s.attemptRecord("40000000-0000-4000-8000-000000000004") - attempt["actor_id"] = "999" - value["attempts"] = []any{attempt} - }, - } - for name, mutate := range registryCases { - path := filepath.Join(s.work, "rejected-state-"+name+".json") - value := s.copyObject(s.submissionRegistry) - mutate(value) - s.writeJSON(path, value) - s.failure(s.teamProposeArgs(path, "50000000-0000-4000-8000-000000000005", "100", s.captain.SigningPrivate, filepath.Join(s.work, "rejected-state-"+name+".proposal.json"), "100")...) +func (s *eventSuite) successWithEnv(values map[string]string, arguments ...string) cliResponse { + command := exec.Command(s.binary, arguments...) + command.Dir = s.work + command.Env = append(os.Environ(), "GOCOVERDIR="+s.coverage) + for key, value := range values { + command.Env = append(command.Env, key+"="+value) } + output, err := command.CombinedOutput() + s.Require().NoError(err, string(output)) + var response cliResponse + s.Require().NoError(json.Unmarshal(output, &response), string(output)) + s.True(response.OK, response.Error) + return response } -func (s *eventSuite) TestRegistryPhaseMembershipAndReplayRejections() { - disabled := filepath.Join(s.work, "disabled-registry.json") - disabledValue := s.copyObject(s.registry) - disabledValue["enabled"] = false - disabledValue["disabled_reason"] = "maintenance" - s.writeJSON(disabled, disabledValue) - s.failure(s.teamProposeArgs(disabled, "50000000-0000-4000-8000-000000000005", "100", s.captain.SigningPrivate, filepath.Join(s.work, "disabled.proposal.json"), "100")...) - s.failure(s.teamConsentArgs(disabled, s.proposal, "100", s.captain.SigningPrivate, filepath.Join(s.work, "disabled.consent.json"))...) - s.failure(s.teamVerifyArgsWithRegistry(disabled, s.proposal, s.issuedAt(s.proposal), filepath.Join(s.work, "disabled.verified.json"), []string{s.captainConsent, s.teammateConsent}, []string{"100=" + s.issuedAt(s.captainConsent), "200=" + s.issuedAt(s.teammateConsent)})...) - s.failure(s.submissionVerifyArgsWithRegistry(s.registry, s.submission, s.bundle, s.metadata, "100", s.issuedAt(s.submission), filepath.Join(s.work, "formation-submission.json"))...) - - activeFormation := filepath.Join(s.work, "active-formation-registry.json") - activeFormationValue := s.copyObject(s.submissionRegistry) - activeFormationValue["phase"] = "formation_open" - s.writeJSON(activeFormation, activeFormationValue) - s.failure(s.teamProposeArgs(activeFormation, s.teamID, "100", s.captain.SigningPrivate, filepath.Join(s.work, "active-team-id.proposal.json"), "100", "200")...) - s.failure(s.teamProposeArgs(activeFormation, "50000000-0000-4000-8000-000000000005", "100", s.captain.SigningPrivate, filepath.Join(s.work, "active-member.proposal.json"), "100")...) - s.failure(s.teamConsentArgs(activeFormation, s.proposal, "100", s.captain.SigningPrivate, filepath.Join(s.work, "active-team.consent.json"))...) - s.failure(s.teamVerifyArgsWithRegistry(activeFormation, s.proposal, s.issuedAt(s.proposal), filepath.Join(s.work, "active-team.verified.json"), []string{s.captainConsent, s.teammateConsent}, []string{"100=" + s.issuedAt(s.captainConsent), "200=" + s.issuedAt(s.teammateConsent)})...) - - missingTeam := filepath.Join(s.work, "missing-team-registry.json") - missingTeamValue := s.copyObject(s.submissionRegistry) - missingTeamValue["teams"] = []any{} - s.writeJSON(missingTeam, missingTeamValue) - s.failure(s.submissionVerifyArgsWithRegistry(missingTeam, s.submission, s.bundle, s.metadata, "100", s.issuedAt(s.submission), filepath.Join(s.work, "missing-team-submission.json"))...) - - replayed := filepath.Join(s.work, "replayed-attempt-registry.json") - replayedValue := s.copyObject(s.submissionRegistry) - replayedValue["attempts"] = []any{s.attemptRecord(s.attemptID)} - s.writeJSON(replayed, replayedValue) - s.failure(s.submissionVerifyArgsWithRegistry(replayed, s.submission, s.bundle, s.metadata, "100", s.issuedAt(s.submission), filepath.Join(s.work, "replayed-submission.json"))...) - - perTeam := filepath.Join(s.work, "per-team-quota-registry.json") - perTeamValue := s.copyObject(s.submissionRegistry) - attempts := make([]any, 0, 10) - for index := range 10 { - attempts = append(attempts, s.attemptRecord(fmt.Sprintf("%08x-0000-4000-8000-%012x", 0x41000000+index, index))) - } - perTeamValue["attempts"] = attempts - s.writeJSON(perTeam, perTeamValue) - s.failure(s.submissionVerifyArgsWithRegistry(perTeam, s.submission, s.bundle, s.metadata, "100", s.issuedAt(s.submission), filepath.Join(s.work, "per-team-submission.json"))...) - - total := filepath.Join(s.work, "total-quota-registry.json") - totalValue := s.copyObject(s.submissionRegistry) - attempts = make([]any, 0, 200) - for index := range 200 { - attempts = append(attempts, s.attemptRecord(fmt.Sprintf("%08x-0000-4000-8000-%012x", 0x42000000+index, index))) - } - totalValue["attempts"] = attempts - s.writeJSON(total, totalValue) - s.failure(s.submissionVerifyArgsWithRegistry(total, s.submission, s.bundle, s.metadata, "100", s.issuedAt(s.submission), filepath.Join(s.work, "total-submission.json"))...) +func (s *eventSuite) failure(arguments ...string) { + code, output := s.raw(-1, arguments...) + s.NotEqual(0, code, string(output)) + var response cliResponse + s.Require().NoError(json.Unmarshal(output, &response), string(output)) + s.False(response.OK) + s.NotEmpty(response.Error) } -func (s *eventSuite) TestTeamAndSubmissionRejections() { - proposalCases := map[string]func(map[string]any){ - "identity": func(value map[string]any) { - value["v"] = 1 - }, - "invalid-reference": func(value map[string]any) { - value["event"].(map[string]any)["event_id"] = "A" - }, - "size": func(value map[string]any) { - value["members"] = []any{} - }, - "window": func(value map[string]any) { - value["expires_at"] = value["issued_at"] - }, - "member": func(value map[string]any) { - value["members"].([]any)[0].(map[string]any)["signing_kid"] = strings.Repeat("b", 64) - }, - "sorted": func(value map[string]any) { - members := value["members"].([]any) - value["members"] = []any{members[1], members[0]} - }, - "proposer": func(value map[string]any) { - value["proposer"].(map[string]any)["actor_id"] = "999" - }, - "signature": func(value map[string]any) { - value["signature"].(map[string]any)["alg"] = "wrong" - }, - } - for name, mutate := range proposalCases { - path := filepath.Join(s.work, "rejected-proposal-"+name+".json") - value := s.copyObject(s.proposal) - mutate(value) - s.writeJSON(path, value) - s.failure(s.teamConsentArgs(s.registry, path, "100", s.captain.SigningPrivate, filepath.Join(s.work, "rejected-proposal-"+name+".consent.json"))...) - } - s.failure(s.teamConsentArgs(s.registry, s.proposal, "999", s.captain.SigningPrivate, filepath.Join(s.work, "missing-consenter.consent.json"))...) - - consentOutput := filepath.Join(s.work, "existing-consent.json") - s.Require().NoError(os.WriteFile(consentOutput, []byte("already exists"), 0o600)) - s.failure(s.teamConsentArgs(s.registry, s.proposal, "100", s.captain.SigningPrivate, consentOutput)...) - - validTimes := []string{"100=" + s.issuedAt(s.captainConsent), "200=" + s.issuedAt(s.teammateConsent)} - s.failure(s.teamVerifyArgs(s.proposal, "not-a-time", filepath.Join(s.work, "bad-proposal-time.json"), []string{s.captainConsent, s.teammateConsent}, validTimes)...) - s.failure(s.teamVerifyArgs(filepath.Join(s.work, "rejected-proposal-identity.json"), s.issuedAt(s.proposal), filepath.Join(s.work, "bad-proposal.json"), []string{s.captainConsent, s.teammateConsent}, validTimes)...) - s.failure(s.teamVerifyArgs(s.proposal, s.issuedAt(s.proposal), filepath.Join(s.work, "duplicate-consent.json"), []string{s.captainConsent, s.captainConsent}, []string{"100=" + s.issuedAt(s.captainConsent), "999=" + s.issuedAt(s.captainConsent)})...) - s.failure(s.teamVerifyArgs(s.proposal, s.issuedAt(s.proposal), filepath.Join(s.work, "missing-consent-time.json"), []string{s.captainConsent, s.teammateConsent}, []string{"100=" + s.issuedAt(s.captainConsent), "999=" + s.issuedAt(s.captainConsent)})...) - s.failure(s.teamVerifyArgs(s.proposal, s.issuedAt(s.proposal), filepath.Join(s.work, "bad-consent-time.json"), []string{s.captainConsent, s.teammateConsent}, []string{"100=not-a-time", "200=" + s.issuedAt(s.teammateConsent)})...) - - consentCases := map[string]func(map[string]any){ - "identity": func(value map[string]any) { - value["v"] = 1 - }, - "invalid-reference": func(value map[string]any) { - value["event"].(map[string]any)["event_id"] = "A" - }, - "window": func(value map[string]any) { - value["expires_at"] = value["issued_at"] - }, - "signer": func(value map[string]any) { - value["key_epoch"] = 2 - }, - "signature-metadata": func(value map[string]any) { - value["signature"].(map[string]any)["alg"] = "wrong" - }, - "signature-encoding": func(value map[string]any) { - value["signature"].(map[string]any)["value"] = "!" - }, - } - for name, mutate := range consentCases { - path := filepath.Join(s.work, "rejected-consent-"+name+".json") - value := s.copyObject(s.captainConsent) - mutate(value) - s.writeJSON(path, value) - s.failure(s.teamVerifyArgs(s.proposal, s.issuedAt(s.proposal), filepath.Join(s.work, "rejected-consent-"+name+".verified.json"), []string{path, s.teammateConsent}, validTimes)...) - } - - prepareOutput := filepath.Join(s.work, "existing-submission.json") - s.Require().NoError(os.WriteFile(prepareOutput, []byte("already exists"), 0o600)) - s.failure("submission", "prepare", "--event", s.event, "--input", s.bundle, "--metadata", s.metadata, "--team-id", s.teamID, "--attempt-id", "50000000-0000-4000-8000-000000000005", "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", prepareOutput) - s.failure("submission", "prepare", "--event", s.event, "--input", s.bundle, "--team-id", "not-a-uuid", "--attempt-id", s.attemptID, "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--output", filepath.Join(s.work, "bad-submission-identity.json")) - - submissionCases := map[string]func(map[string]any){ - "identity": func(value map[string]any) { - value["v"] = 1 - }, - "invalid-reference": func(value map[string]any) { - value["event"].(map[string]any)["event_id"] = "A" - }, - "window": func(value map[string]any) { - value["expires_at"] = value["issued_at"] - }, - "signer": func(value map[string]any) { - value["key_epoch"] = 2 - }, - "signature": func(value map[string]any) { - value["signature"].(map[string]any)["alg"] = "wrong" - }, - } - for name, mutate := range submissionCases { - path := filepath.Join(s.work, "rejected-submission-"+name+".json") - value := s.copyObject(s.submission) - mutate(value) - s.writeJSON(path, value) - s.failure(s.submissionVerifyArgs(path, s.bundle, s.metadata, "100", s.issuedAt(s.submission), filepath.Join(s.work, "rejected-submission-"+name+".verified.json"))...) +func (s *eventSuite) failureWithEnv(values map[string]string, arguments ...string) { + command := exec.Command(s.binary, arguments...) + command.Dir = s.work + command.Env = append(os.Environ(), "GOCOVERDIR="+s.coverage) + for key, value := range values { + command.Env = append(command.Env, key+"="+value) } - s.failure(s.submissionVerifyArgs(s.submission, s.bundle, s.metadata, "100", "not-a-time", filepath.Join(s.work, "bad-submission-time.verified.json"))...) - s.failure(s.submissionVerifyArgs(s.submission, s.bundle, "", "100", s.issuedAt(s.submission), filepath.Join(s.work, "missing-submission-metadata.verified.json"))...) -} - -func (s *eventSuite) TestSubmissionVerification() { - verified := filepath.Join(s.work, "submission-verified.json") - s.success("submission", "verify", "--event", s.event, "--registry", s.submissionRegistry, "--request", s.submission, "--bundle", s.bundle, "--metadata", s.metadata, "--expect-actor-id", "100", "--source-time", s.issuedAt(s.submission), "--output", verified) - s.failure("submission", "verify", "--event", s.event, "--registry", s.submissionRegistry, "--request", s.submission, "--bundle", s.bundle, "--metadata", s.metadata, "--expect-actor-id", "200", "--source-time", s.issuedAt(s.submission), "--output", filepath.Join(s.work, "wrong-submitter.json")) - tampered := filepath.Join(s.work, "tampered-bundle.zip") - s.Require().NoError(os.WriteFile(tampered, []byte("tampered"), 0o600)) - s.failure("submission", "verify", "--event", s.event, "--registry", s.submissionRegistry, "--request", s.submission, "--bundle", tampered, "--metadata", s.metadata, "--expect-actor-id", "100", "--source-time", s.issuedAt(s.submission), "--output", filepath.Join(s.work, "tampered-submission.json")) + output, err := command.CombinedOutput() + s.Require().Error(err, string(output)) + var response cliResponse + s.Require().NoError(json.Unmarshal(output, &response), string(output)) + s.False(response.OK) } -func (s *eventSuite) TestSignedEncryptedStream() { - for _, member := range []keyResult{s.captain, s.teammate} { - plain := filepath.Join(s.work, member.SigningKeyID+".log") - s.success("decverify", "--input", s.stream, "--output", plain, "--context", s.streamBinding, "--ver-public-key", s.captain.SigningPublic, "--dec-private-key", member.RecipientPrivate, "--passphrase-file", s.passphrase) - data, err := os.ReadFile(plain) - s.Require().NoError(err) - s.Equal([]byte("private judge output\n"), data) +func (s *eventSuite) raw(expected int, arguments ...string) (int, []byte) { + command := exec.Command(s.binary, arguments...) + command.Dir = s.work + command.Env = append(os.Environ(), "GOCOVERDIR="+s.coverage) + output, err := command.CombinedOutput() + code := 0 + if err != nil { + var exitError *exec.ExitError + s.Require().ErrorAs(err, &exitError) + code = exitError.ExitCode() } - s.failure("decverify", "--input", s.stream, "--output", filepath.Join(s.work, "outsider.log"), "--context", s.streamBinding, "--ver-public-key", s.captain.SigningPublic, "--dec-private-key", s.outsider.RecipientPrivate, "--passphrase-file", s.passphrase) - wrongContext := filepath.Join(s.work, "wrong-stream-binding.json") - wrong := s.readObject(s.streamBinding) - wrong["artifact_id"] = "different-run" - s.writeJSON(wrongContext, wrong) - s.failure("decverify", "--input", s.stream, "--output", filepath.Join(s.work, "wrong-context.log"), "--context", wrongContext, "--ver-public-key", s.captain.SigningPublic, "--dec-private-key", s.captain.RecipientPrivate, "--passphrase-file", s.passphrase) + if expected >= 0 { + s.Equal(expected, code, string(output)) + } + return code, output } -func (s *eventSuite) TestStreamEnvelopeRejections() { - badBinding := filepath.Join(s.work, "bad-stream-binding.json") - value := s.copyObject(s.streamBinding) - value["purpose"] = "" - s.writeJSON(badBinding, value) - s.failure("sigcrypt", "--input", s.bundle, "--output", filepath.Join(s.work, "bad-binding.eventctl"), "--context", badBinding, "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase, "--enc-public-key", s.captain.RecipientPublic) - - invalidMagic := filepath.Join(s.work, "invalid-magic.eventctl") - s.Require().NoError(os.WriteFile(invalidMagic, []byte("not an eventctl stream"), 0o600)) - s.failure(s.decverifyArgs(invalidMagic, filepath.Join(s.work, "invalid-magic.log"))...) - - invalidSize := filepath.Join(s.work, "invalid-size.eventctl") - s.writeRawStream(invalidSize, uint32((64<<20)+1), nil, nil) - s.failure(s.decverifyArgs(invalidSize, filepath.Join(s.work, "invalid-size.log"))...) - - invalidHeader := filepath.Join(s.work, "invalid-header.eventctl") - s.writeRawStream(invalidHeader, 1, []byte("{"), nil) - s.failure(s.decverifyArgs(invalidHeader, filepath.Join(s.work, "invalid-header.log"))...) - - invalidSignature := filepath.Join(s.work, "invalid-signature.eventctl") - s.mutateStream(invalidSignature, func(header map[string]any) { - header["signature"].(map[string]any)["value"] = "!" - }, func(payload []byte) []byte { return payload }) - s.failure(s.decverifyArgs(invalidSignature, filepath.Join(s.work, "invalid-signature.log"))...) - - invalidCiphertext := filepath.Join(s.work, "invalid-stream-ciphertext.eventctl") - s.mutateStream(invalidCiphertext, func(map[string]any) {}, func([]byte) []byte { return []byte("not an age payload") }) - s.failure(s.decverifyArgs(invalidCiphertext, filepath.Join(s.work, "invalid-stream-ciphertext.log"))...) - - tamperedCiphertext := filepath.Join(s.work, "tampered-stream-ciphertext.eventctl") - s.mutateStream(tamperedCiphertext, func(map[string]any) {}, func(payload []byte) []byte { - copy := append([]byte(nil), payload...) - copy[len(copy)-1] ^= 1 - return copy - }) - s.failure(s.decverifyArgs(tamperedCiphertext, filepath.Join(s.work, "tampered-stream-ciphertext.log"))...) - - wrongSigner := filepath.Join(s.work, "wrong-header-signer.eventctl") - s.writeSignedStream(wrongSigner, func(header *streamHeader) { - header.Signer.KeyID = strings.Repeat("b", 64) - }) - s.failure(s.decverifyArgs(wrongSigner, filepath.Join(s.work, "wrong-header-signer.log"))...) - - wrongSize := filepath.Join(s.work, "wrong-header-size.eventctl") - s.writeSignedStream(wrongSize, func(header *streamHeader) { - header.PayloadSize++ - }) - s.failure(s.decverifyArgs(wrongSize, filepath.Join(s.work, "wrong-header-size.log"))...) - - wrongDigest := filepath.Join(s.work, "wrong-header-digest.eventctl") - s.writeSignedStream(wrongDigest, func(header *streamHeader) { - header.PayloadSHA256 = strings.Repeat("b", 64) - }) - s.failure(s.decverifyArgs(wrongDigest, filepath.Join(s.work, "wrong-header-digest.log"))...) - s.failure(s.decverifyArgs(s.stream, s.stream)...) - +func (s *eventSuite) write(name, content string) string { + file := filepath.Join(s.work, name) + s.Require().NoError(os.MkdirAll(filepath.Dir(file), 0o755)) + s.Require().NoError(os.WriteFile(file, []byte(content), 0o600)) + return file } -func (s *eventSuite) TestInputFailures() { - s.failure("key-gen", "--out", filepath.Join(s.work, "no-passphrase")) - empty := filepath.Join(s.work, "empty-passphrase.txt") - s.Require().NoError(os.WriteFile(empty, nil, 0o600)) - s.failure("key-gen", "--out", filepath.Join(s.work, "empty-passphrase"), "--passphrase-file", empty) - s.failure("identity", "register", "--event", filepath.Join(s.work, "missing-event.json"), "--actor-id", "100", "--sig-private-key", s.captain.SigningPrivate, "--recipient-public-key", s.captain.RecipientPublic, "--passphrase-file", s.passphrase, "--output", filepath.Join(s.work, "missing-event-registration.json")) - s.failure("team", "verify", "--event", s.event, "--registry", s.registry, "--proposal", s.proposal, "--proposal-source-time", s.issuedAt(s.proposal), "--consent", s.captainConsent, "--consent-source-time", "wrong", "--output", filepath.Join(s.work, "bad-consent-time.json")) - s.failure("sigcrypt", "--input", s.bundle, "--output", filepath.Join(s.work, "no-recipients.eventctl"), "--context", s.streamBinding, "--sig-private-key", s.captain.SigningPrivate, "--passphrase-file", s.passphrase) +func (s *eventSuite) read(file string) []byte { + data, err := os.ReadFile(file) + s.Require().NoError(err) + return data } -func (s *eventSuite) keyGen(name string) keyResult { - directory := filepath.Join(s.work, name) - result := s.success("key-gen", "--out", directory, "--passphrase-file", s.passphrase) - var value keyResult - s.Require().NoError(json.Unmarshal(result.Result, &value)) +func (s *eventSuite) manifest(file string) manifest { + entries := s.tarEntries(file) + var value manifest + s.Require().NoError(json.Unmarshal(entries["manifest.json"], &value)) return value } -func (s *eventSuite) register(actorID, registrationID string, key keyResult) string { - request := filepath.Join(s.work, actorID+".registration.json") - s.success("identity", "register", "--event", s.event, "--actor-id", actorID, "--registration-id", registrationID, "--sig-private-key", key.SigningPrivate, "--recipient-public-key", key.RecipientPublic, "--passphrase-file", s.passphrase, "--output", request) - return request -} - -func (s *eventSuite) verifiedIdentities(registrations ...string) []any { - identities := make([]any, 0, len(registrations)) - for _, registration := range registrations { - verified := filepath.Join(s.work, filepath.Base(registration)+".verified.json") - actorID := s.readObject(registration)["actor_id"].(string) - s.success("identity", "verify", "--event", s.event, "--input", registration, "--expect-actor-id", actorID, "--source-time", s.issuedAt(registration), "--output", verified) - identities = append(identities, s.readObject(verified)) +func (s *eventSuite) tarEntries(file string) map[string][]byte { + reader := tar.NewReader(bytes.NewReader(s.read(file))) + entries := map[string][]byte{} + for { + header, err := reader.Next() + if err == io.EOF { + return entries + } + s.Require().NoError(err) + data, err := io.ReadAll(reader) + s.Require().NoError(err) + entries[header.Name] = data } - return identities } -func (s *eventSuite) writeRegistry(path, phase string, identities, teams, attempts []any) { - s.writeJSON(path, map[string]any{"v": 2, "kind": "event-registry", "event": s.readObject(s.captainReg)["event"], "revision": 0, "phase": phase, "enabled": true, "disabled_reason": "", "identities": identities, "teams": teams, "attempts": attempts}) -} - -func (s *eventSuite) issuedAt(path string) string { return s.readObject(path)["issued_at"].(string) } - -func (s *eventSuite) readObject(path string) map[string]any { - data := s.readBytes(path) - var value map[string]any - s.Require().NoError(json.Unmarshal(data, &value)) - return value -} - -func (s *eventSuite) copyObject(path string) map[string]any { - return s.copyValue(s.readObject(path)) +func (s *eventSuite) writeTar(file string, entries map[string][]byte) { + names := sortedKeys(entries) + var output bytes.Buffer + writer := tar.NewWriter(&output) + for _, name := range names { + data := entries[name] + s.Require().NoError(writer.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(data))})) + _, err := writer.Write(data) + s.Require().NoError(err) + } + s.Require().NoError(writer.Close()) + s.Require().NoError(os.WriteFile(file, output.Bytes(), 0o600)) } -func (s *eventSuite) copyValue(value map[string]any) map[string]any { - data, err := json.Marshal(value) - s.Require().NoError(err) - var copy map[string]any - s.Require().NoError(json.Unmarshal(data, ©)) - return copy +func (s *eventSuite) writeRawTar(name string, entries ...rawTarEntry) string { + file := filepath.Join(s.work, name) + var output bytes.Buffer + writer := tar.NewWriter(&output) + for _, entry := range entries { + typeFlag := entry.Type + if typeFlag == 0 { + typeFlag = tar.TypeReg + } + header := &tar.Header{Name: entry.Name, Mode: 0o644, Size: int64(len(entry.Data)), Typeflag: typeFlag} + if typeFlag == tar.TypeDir { + header.Size = 0 + } + s.Require().NoError(writer.WriteHeader(header)) + if len(entry.Data) != 0 { + _, err := writer.Write(entry.Data) + s.Require().NoError(err) + } + } + s.Require().NoError(writer.Close()) + s.Require().NoError(os.WriteFile(file, output.Bytes(), 0o600)) + return file } -func (s *eventSuite) readBytes(path string) []byte { - data, err := os.ReadFile(path) +func (s *eventSuite) writeTruncatedTar(name string) string { + file := filepath.Join(s.work, name) + var output bytes.Buffer + writer := tar.NewWriter(&output) + s.Require().NoError(writer.WriteHeader(&tar.Header{Name: "manifest.json", Mode: 0o644, Size: 10, Typeflag: tar.TypeReg})) + _, err := writer.Write([]byte("x")) s.Require().NoError(err) - return data + s.Require().NoError(os.WriteFile(file, output.Bytes(), 0o600)) + return file } -func (s *eventSuite) success(args ...string) response { return s.invoke(true, nil, args...) } - -func (s *eventSuite) successWithEnv(values map[string]string, args ...string) response { - return s.invoke(true, values, args...) -} - -func (s *eventSuite) failure(args ...string) response { return s.invoke(false, nil, args...) } - -func (s *eventSuite) failureWithEnv(values map[string]string, args ...string) response { - return s.invoke(false, values, args...) +func (s *eventSuite) writeOversizedHeaderTar(name string) string { + file := filepath.Join(s.work, name) + var output bytes.Buffer + writer := tar.NewWriter(&output) + s.Require().NoError(writer.WriteHeader(&tar.Header{Name: "manifest.json", Mode: 0o644, Size: 64<<20 + 1, Typeflag: tar.TypeReg})) + s.Require().NoError(os.WriteFile(file, output.Bytes(), 0o600)) + return file } -func (s *eventSuite) invoke(wantSuccess bool, values map[string]string, args ...string) response { - command := exec.Command(s.binary, args...) - command.Env = append(os.Environ(), "GOCOVERDIR="+s.coverage) - for key, value := range values { - command.Env = append(command.Env, key+"="+value) - } - output, err := command.CombinedOutput() - if wantSuccess { - s.Require().NoError(err, "%s\n%s", strings.Join(args, " "), output) - } else { - s.Require().Error(err, "%s unexpectedly succeeded\n%s", strings.Join(args, " "), output) - } - var value response - s.Require().NoError(json.Unmarshal(bytes.TrimSpace(output), &value), "%s", output) - s.Equal(wantSuccess, value.OK, "%s", output) - if !wantSuccess { - s.NotEmpty(value.Error) - } - return value +func (s *eventSuite) replaceManifest(name, source string, value []byte) string { + entries := s.tarEntries(source) + entries["manifest.json"] = value + output := filepath.Join(s.work, name) + s.writeTar(output, entries) + return output } -func (s *eventSuite) raw(expectedExit int, args ...string) []byte { - command := exec.Command(s.binary, args...) - command.Env = append(os.Environ(), "GOCOVERDIR="+s.coverage) - output, err := command.CombinedOutput() - if expectedExit == 0 { - s.Require().NoError(err, "%s\n%s", strings.Join(args, " "), output) - } else { - s.Require().Error(err) - } +func (s *eventSuite) mutateManifest(name, source string, mutate func(map[string]any)) string { + entries := s.tarEntries(source) + var value map[string]any + s.Require().NoError(json.Unmarshal(entries["manifest.json"], &value)) + mutate(value) + entries["manifest.json"] = s.json(value) + output := filepath.Join(s.work, name) + s.writeTar(output, entries) return output } -func (s *eventSuite) writeJSON(path string, value any) { - data, err := json.Marshal(value) - s.Require().NoError(err) - s.Require().NoError(os.WriteFile(path, append(data, '\n'), 0o600)) +func (s *eventSuite) innerTar(entries ...rawTarEntry) []byte { + var output bytes.Buffer + writer := tar.NewWriter(&output) + for _, entry := range entries { + typeFlag := entry.Type + if typeFlag == 0 { + typeFlag = tar.TypeReg + } + header := &tar.Header{Name: entry.Name, Mode: 0o644, Size: int64(len(entry.Data)), Typeflag: typeFlag} + s.Require().NoError(writer.WriteHeader(header)) + _, err := writer.Write(entry.Data) + s.Require().NoError(err) + } + s.Require().NoError(writer.Close()) + return output.Bytes() } -func (s *eventSuite) writePassphraseEncrypted(path string, data []byte) { - passphrase := strings.TrimSpace(string(s.readBytes(s.passphrase))) - recipient, err := age.NewScryptRecipient(passphrase) - s.Require().NoError(err) +func (s *eventSuite) signedFeedback(name string, payload []byte) string { + recipient := s.hybridRecipient(s.alice.EncryptionPublic) var encrypted bytes.Buffer writer, err := age.Encrypt(&encrypted, recipient) s.Require().NoError(err) - _, err = writer.Write(data) + _, err = writer.Write(payload) s.Require().NoError(err) s.Require().NoError(writer.Close()) - s.Require().NoError(os.WriteFile(path, encrypted.Bytes(), 0o600)) + return s.writeSignedFeedback(name, encrypted.Bytes()) } -func (s *eventSuite) identityVerifyArgs(input, actorID, sourceTime, output string) []string { - return []string{"identity", "verify", "--event", s.event, "--input", input, "--expect-actor-id", actorID, "--source-time", sourceTime, "--output", output} +func (s *eventSuite) resignFeedbackPayload(name, source string, mutate func([]byte)) string { + entries := s.tarEntries(source) + mutate(entries["payload.age"]) + return s.writeSignedFeedbackEntries(name, entries) } -func (s *eventSuite) teamProposeArgs(registry, teamID, actorID, signingPath, output string, members ...string) []string { - args := []string{"team", "propose", "--event", s.event, "--registry", registry} - if teamID != "" { - args = append(args, "--team-id", teamID) - } - args = append(args, "--actor-id", actorID) - for _, member := range members { - args = append(args, "--member", member) - } - return append(args, "--sig-private-key", signingPath, "--passphrase-file", s.passphrase, "--output", output) +func (s *eventSuite) writeSignedFeedback(name string, encrypted []byte) string { + return s.writeSignedFeedbackEntries(name, map[string][]byte{"payload.age": encrypted}) } -func (s *eventSuite) teamConsentArgs(registry, proposal, actorID, signingPath, output string) []string { - return []string{"team", "consent", "--event", s.event, "--registry", registry, "--proposal", proposal, "--actor-id", actorID, "--sig-private-key", signingPath, "--passphrase-file", s.passphrase, "--output", output} +func (s *eventSuite) writeSignedFeedbackEntries(name string, entries map[string][]byte) string { + payload := entries["payload.age"] + digest := sha256.Sum256(payload) + value := manifest{ + Protocol: "eventctl/v3", + Kind: "feedback", + Files: []manifestFile{{Path: "payload.age", SHA256: stringHex(digest[:]), Size: int64(len(payload))}}, + SigningPublicKey: string(s.read(s.alice.SignaturePublic)), + } + value.Signature = s.signManifest(value) + entries = cloneEntries(entries) + entries["manifest.json"] = s.json(value) + output := filepath.Join(s.work, name) + s.writeTar(output, entries) + return output } -func (s *eventSuite) teamVerifyArgs(proposal, proposalTime, output string, consents, consentTimes []string) []string { - return s.teamVerifyArgsWithRegistry(s.registry, proposal, proposalTime, output, consents, consentTimes) +func (s *eventSuite) signManifest(value manifest) string { + value.Signature = "" + payload := append([]byte("eventctl/v3\x00feedback\x00"), s.json(value)...) + signature := ed25519.Sign(s.ed25519Private(s.alice.SignaturePrivate), payload) + return base64.RawStdEncoding.EncodeToString(signature) } -func (s *eventSuite) teamVerifyArgsWithRegistry(registry, proposal, proposalTime, output string, consents, consentTimes []string) []string { - args := []string{"team", "verify", "--event", s.event, "--registry", registry, "--proposal", proposal, "--proposal-source-time", proposalTime} - for _, consent := range consents { - args = append(args, "--consent", consent) - } - for _, consentTime := range consentTimes { - args = append(args, "--consent-source-time", consentTime) - } - return append(args, "--output", output) +func (s *eventSuite) hybridRecipient(file string) age.Recipient { + value, err := age.ParseHybridRecipient(strings.TrimSpace(string(s.read(file)))) + s.Require().NoError(err) + return value } -func (s *eventSuite) submissionVerifyArgs(request, bundle, metadata, actorID, sourceTime, output string) []string { - return s.submissionVerifyArgsWithRegistry(s.submissionRegistry, request, bundle, metadata, actorID, sourceTime, output) +func (s *eventSuite) ed25519Private(file string) ed25519.PrivateKey { + block, rest := pem.Decode(s.read(file)) + s.Require().NotNil(block) + s.Empty(bytes.TrimSpace(rest)) + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + s.Require().NoError(err) + key, ok := parsed.(ed25519.PrivateKey) + s.Require().True(ok) + return key } -func (s *eventSuite) submissionVerifyArgsWithRegistry(registry, request, bundle, metadata, actorID, sourceTime, output string) []string { - args := []string{"submission", "verify", "--event", s.event, "--registry", registry, "--request", request, "--bundle", bundle} - if metadata != "" { - args = append(args, "--metadata", metadata) - } - return append(args, "--expect-actor-id", actorID, "--source-time", sourceTime, "--output", output) +func (s *eventSuite) ecdsaPrivate(name string) string { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + s.Require().NoError(err) + data, err := x509.MarshalPKCS8PrivateKey(key) + s.Require().NoError(err) + return s.pem(name, "PRIVATE KEY", data) } -func (s *eventSuite) attemptRecord(attemptID string) map[string]any { - return map[string]any{"attempt_id": attemptID, "team_id": s.teamID, "actor_id": "100", "submission_sha256": strings.Repeat("a", 64), "payload_sha256": strings.Repeat("b", 64)} +func (s *eventSuite) ecdsaPublic(name string) string { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + s.Require().NoError(err) + data, err := x509.MarshalPKIXPublicKey(key.Public()) + s.Require().NoError(err) + return s.pem(name, "PUBLIC KEY", data) } -func (s *eventSuite) decverifyArgs(input, output string) []string { - return []string{"decverify", "--input", input, "--output", output, "--context", s.streamBinding, "--ver-public-key", s.captain.SigningPublic, "--dec-private-key", s.captain.RecipientPrivate, "--passphrase-file", s.passphrase} +func (s *eventSuite) pem(name, kind string, data []byte) string { + return s.write(name, string(pem.EncodeToMemory(&pem.Block{Type: kind, Bytes: data}))) } -func (s *eventSuite) writeRawStream(path string, headerSize uint32, header, payload []byte) { - data := make([]byte, 12+len(header)+len(payload)) - copy(data, []byte{'E', 'V', 'T', 'C', 'T', 'L', 1, 0}) - binary.BigEndian.PutUint32(data[8:12], headerSize) - copy(data[12:], header) - copy(data[12+len(header):], payload) - s.Require().NoError(os.WriteFile(path, data, 0o600)) +func (s *eventSuite) json(value any) []byte { + data, err := json.Marshal(value) + s.Require().NoError(err) + return data } -func (s *eventSuite) mutateStream(path string, mutateHeader func(map[string]any), mutatePayload func([]byte) []byte) { - data := s.readBytes(s.stream) - headerSize := binary.BigEndian.Uint32(data[8:12]) - headerEnd := 12 + int(headerSize) - var header map[string]any - s.Require().NoError(json.Unmarshal(data[12:headerEnd], &header)) - mutateHeader(header) - headerData, err := json.Marshal(header) - s.Require().NoError(err) - s.writeRawStream(path, uint32(len(headerData)), headerData, mutatePayload(data[headerEnd:])) +func cloneEntries(entries map[string][]byte) map[string][]byte { + result := make(map[string][]byte, len(entries)) + for name, data := range entries { + result[name] = append([]byte(nil), data...) + } + return result } -func (s *eventSuite) writeSignedStream(path string, mutate func(*streamHeader)) { - data := s.readBytes(s.stream) - headerSize := binary.BigEndian.Uint32(data[8:12]) - headerEnd := 12 + int(headerSize) - var header streamHeader - s.Require().NoError(json.Unmarshal(data[12:headerEnd], &header)) - mutate(&header) - unsigned := header - unsigned.Signature = streamSignature{} - unsignedData, err := json.Marshal(unsigned) - s.Require().NoError(err) - message := append([]byte("eventctl:eventctl/v2:stream.sigcrypt\x00"), append(unsignedData, '\n')...) - private := s.readSigningPrivate() - header.Signature = streamSignature{Algorithm: "Ed25519", KeyID: s.captain.SigningKeyID, Value: base64.RawURLEncoding.EncodeToString(ed25519.Sign(private, message))} - headerData, err := json.Marshal(header) - s.Require().NoError(err) - s.writeRawStream(path, uint32(len(headerData)), headerData, data[headerEnd:]) +func stringHex(value []byte) string { + const alphabet = "0123456789abcdef" + encoded := make([]byte, len(value)*2) + for index, byteValue := range value { + encoded[index*2] = alphabet[byteValue>>4] + encoded[index*2+1] = alphabet[byteValue&0x0f] + } + return string(encoded) } -func (s *eventSuite) readSigningPrivate() ed25519.PrivateKey { - identity, err := age.NewScryptIdentity(strings.TrimSpace(string(s.readBytes(s.passphrase)))) - s.Require().NoError(err) - reader, err := age.Decrypt(bytes.NewReader(s.readBytes(s.captain.SigningPrivate)), identity) - s.Require().NoError(err) - plain, err := io.ReadAll(reader) - s.Require().NoError(err) - var value struct { - PrivateKey string `json:"private"` +func sortedKeys(values map[string][]byte) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) } - s.Require().NoError(json.Unmarshal(plain, &value)) - private, err := base64.RawURLEncoding.DecodeString(value.PrivateKey) - s.Require().NoError(err) - return ed25519.PrivateKey(private) + sort.Strings(keys) + return keys } From 31bd63e55bfcf1d6fb075f07ce45932527fbdc4c Mon Sep 17 00:00:00 2001 From: Alex Au Date: Sun, 9 Aug 2026 02:12:48 +0800 Subject: [PATCH 2/2] fix: keep the v3 CLI surface minimal --- cmd/eventctl/main.go | 1 + tests/e2e/eventctl_test.go | 1 + 2 files changed, 2 insertions(+) diff --git a/cmd/eventctl/main.go b/cmd/eventctl/main.go index 8131f57..e906243 100644 --- a/cmd/eventctl/main.go +++ b/cmd/eventctl/main.go @@ -55,6 +55,7 @@ func newRoot(stdout, stderr io.Writer) *cobra.Command { } root.SetOut(stdout) root.SetErr(stderr) + root.CompletionOptions.DisableDefaultCmd = true root.AddCommand(versionCommand(), doctorCommand(), keyGenCommand(), teamCommand(), submissionCommand(), sigcryptCommand(), decverifyCommand()) return root } diff --git a/tests/e2e/eventctl_test.go b/tests/e2e/eventctl_test.go index fb03aba..5a78ba3 100644 --- a/tests/e2e/eventctl_test.go +++ b/tests/e2e/eventctl_test.go @@ -141,6 +141,7 @@ func (s *eventSuite) TestVersionAndDoctor() { s.success("doctor") s.failureWithEnv(map[string]string{"EVENTCTL_EVENT_ID": "not-a-repository"}, "doctor") s.failure("doctor", "--unexpected") + s.failure("completion") s.raw(0, "--help") s.raw(0, "team", "--help") }