diff --git a/cmd/ende/init_cmd.go b/cmd/ende/init_cmd.go new file mode 100644 index 0000000..3b77fdd --- /dev/null +++ b/cmd/ende/init_cmd.go @@ -0,0 +1,121 @@ +package main + +import ( + "fmt" + "net/url" + "os/user" + "regexp" + "strings" + + "github.com/kuma/ende/internal/keyring" + "github.com/spf13/cobra" +) + +var initNameSanitizer = regexp.MustCompile(`[^A-Za-z0-9._:@-]+`) + +func newInitCommand() *cobra.Command { + var name string + var keyserverURL string + cmd := &cobra.Command{ + Use: "init", + Short: "Set up Ende for first-time use", + RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(name) == "" { + name = defaultInitName() + } + name = sanitizeInitName(name) + if name == "" { + return fmt.Errorf("--name is required") + } + if strings.TrimSpace(keyserverURL) != "" { + if _, err := url.ParseRequestURI(keyserverURL); err != nil { + return fmt.Errorf("invalid --keyserver URL: %w", err) + } + } + + share, created, err := ensureInitKey(name) + if err != nil { + return err + } + if err := ensureDefaultSigner(name); err != nil { + return err + } + + out := cmd.OutOrStdout() + if created { + fmt.Fprintf(out, "created local key %s\n", name) + } else { + fmt.Fprintf(out, "using existing local key %s\n", name) + } + fmt.Fprintln(out, "") + fmt.Fprintln(out, "Your share code:") + fmt.Fprintf(out, "%s\n", share) + fmt.Fprintln(out, "") + fmt.Fprintln(out, "Next steps:") + fmt.Fprintln(out, "1. Send this share code to a peer, or publish the public profile through your team's keyserver.") + fmt.Fprintln(out, "2. Add a peer with `ende add-peer` when they send you their share code.") + fmt.Fprintf(out, "3. Send a secret with `ende send -t `.\n") + if strings.TrimSpace(keyserverURL) != "" { + fmt.Fprintln(out, "") + fmt.Fprintln(out, "Keyserver:") + fmt.Fprintf(out, "- publish this public profile to %s/v1/peers/%s\n", strings.TrimRight(keyserverURL, "/"), name) + fmt.Fprintf(out, "- peers can fetch your share code from %s/v1/share/%s\n", strings.TrimRight(keyserverURL, "/"), name) + fmt.Fprintln(out, "- the keyserver is a public directory; peers should still pin your key in their local keyring.") + } + return nil + }, + } + cmd.Flags().StringVar(&name, "name", "", "local key id (defaults to the OS username)") + cmd.Flags().StringVar(&keyserverURL, "keyserver", "", "optional keyserver base URL for onboarding guidance") + return cmd +} + +func ensureInitKey(name string) (share string, created bool, err error) { + store, err := keyring.Load() + if err != nil { + return "", false, err + } + if entry, ok := store.Key(name); ok { + share, err := tutorialShareFromEntry(entry) + return share, false, err + } + share, err = tutorialKeygen(name) + return share, true, err +} + +func ensureDefaultSigner(name string) error { + store, err := keyring.Load() + if err != nil { + return err + } + if store.DefaultSigner() == name { + return nil + } + if err := store.SetDefaultSigner(name); err != nil { + return err + } + return store.Save() +} + +func defaultInitName() string { + current, err := user.Current() + if err == nil { + if name := sanitizeInitName(current.Username); name != "" { + return name + } + } + return "me" +} + +func sanitizeInitName(name string) string { + name = strings.TrimSpace(name) + if i := strings.LastIndexAny(name, `\/`); i >= 0 { + name = name[i+1:] + } + name = initNameSanitizer.ReplaceAllString(name, "-") + name = strings.Trim(name, "-") + if len(name) > 128 { + name = name[:128] + } + return name +} diff --git a/cmd/ende/init_cmd_test.go b/cmd/ende/init_cmd_test.go new file mode 100644 index 0000000..377c6d2 --- /dev/null +++ b/cmd/ende/init_cmd_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "bytes" + "strings" + "testing" + + "github.com/kuma/ende/internal/keyring" +) + +func TestInitCommandCreatesLocalKeyAndPrintsShareCode(t *testing.T) { + t.Setenv("ENDE_CONFIG_DIR", t.TempDir()) + + cmd := newInitCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetArgs([]string{"--name", "alice"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("init command failed: %v", err) + } + + got := out.String() + if !strings.Contains(got, "created local key alice") { + t.Fatalf("expected create message, got:\n%s", got) + } + if !strings.Contains(got, sharePrefix) { + t.Fatalf("expected share code, got:\n%s", got) + } + + store, err := keyring.Load() + if err != nil { + t.Fatalf("load keyring: %v", err) + } + if _, ok := store.Key("alice"); !ok { + t.Fatal("expected alice key in keyring") + } + if store.DefaultSigner() != "alice" { + t.Fatalf("expected alice default signer, got %q", store.DefaultSigner()) + } +} + +func TestInitCommandReusesExistingLocalKey(t *testing.T) { + t.Setenv("ENDE_CONFIG_DIR", t.TempDir()) + + first := newInitCommand() + first.SetArgs([]string{"--name", "alice"}) + if err := first.Execute(); err != nil { + t.Fatalf("first init failed: %v", err) + } + + second := newInitCommand() + var out bytes.Buffer + second.SetOut(&out) + second.SetArgs([]string{"--name", "alice"}) + if err := second.Execute(); err != nil { + t.Fatalf("second init failed: %v", err) + } + if !strings.Contains(out.String(), "using existing local key alice") { + t.Fatalf("expected reuse message, got:\n%s", out.String()) + } +} + +func TestInitCommandPrintsKeyserverGuidance(t *testing.T) { + t.Setenv("ENDE_CONFIG_DIR", t.TempDir()) + + cmd := newInitCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetArgs([]string{"--name", "alice", "--keyserver", "https://keys.example.com/"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("init command failed: %v", err) + } + got := out.String() + if !strings.Contains(got, "https://keys.example.com/v1/peers/alice") { + t.Fatalf("expected publish URL, got:\n%s", got) + } + if !strings.Contains(got, "https://keys.example.com/v1/share/alice") { + t.Fatalf("expected share URL, got:\n%s", got) + } +} + +func TestInitCommandRejectsInvalidKeyserverURL(t *testing.T) { + t.Setenv("ENDE_CONFIG_DIR", t.TempDir()) + + cmd := newInitCommand() + cmd.SetArgs([]string{"--name", "alice", "--keyserver", "://bad"}) + + if err := cmd.Execute(); err == nil { + t.Fatal("expected invalid keyserver URL error") + } +} + +func TestSanitizeInitName(t *testing.T) { + got := sanitizeInitName(`domain\Alice Smith!`) + if got != "Alice-Smith" { + t.Fatalf("unexpected sanitized name: %q", got) + } +} diff --git a/cmd/ende/keyserver_client.go b/cmd/ende/keyserver_client.go new file mode 100644 index 0000000..b3c8453 --- /dev/null +++ b/cmd/ende/keyserver_client.go @@ -0,0 +1,292 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "filippo.io/age" + "github.com/kuma/ende/internal/keyring" + "github.com/kuma/ende/internal/sign" +) + +const ( + keyserverSource = "keyserver" + keyserverURLEnv = "ENDE_KEYSERVER_URL" + keyserverTokenEnv = "ENDE_KEYSERVER_TOKEN" + keyserverInsecureHTTPEnv = "ENDE_KEYSERVER_INSECURE_HTTP" +) + +var keyserverClient = &http.Client{Timeout: 10 * time.Second} + +type keyserverProfile struct { + ID string `json:"id"` + Recipient string `json:"recipient"` + SigningPublic string `json:"signing_public"` + DisplayName string `json:"display_name,omitempty"` +} + +type keyserverShareResponse struct { + ShareCode string `json:"share_code"` +} + +type keyserverAuthResponse struct { + Token string `json:"token"` + PeerID string `json:"peer_id"` + Provider string `json:"provider"` + Subject string `json:"subject"` +} + +type keyserverTokenFile struct { + Tokens map[string]string `json:"tokens"` +} + +func keyserverURL(flagValue string, allowInsecureHTTP ...bool) (string, error) { + raw := strings.TrimSpace(flagValue) + if raw == "" { + raw = strings.TrimSpace(os.Getenv(keyserverURLEnv)) + } + if raw == "" { + return "", fmt.Errorf("--keyserver is required (or set %s)", keyserverURLEnv) + } + parsed, err := url.ParseRequestURI(raw) + if err != nil { + return "", fmt.Errorf("invalid keyserver URL: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("keyserver URL must start with http:// or https://") + } + allowInsecure := len(allowInsecureHTTP) > 0 && allowInsecureHTTP[0] + if parsed.Scheme == "http" && !allowInsecure && !envEnabled(keyserverInsecureHTTPEnv) && !isLoopbackHost(parsed.Hostname()) { + return "", fmt.Errorf("remote keyserver URL must use https:// (use --insecure-http only for test environments)") + } + return strings.TrimRight(raw, "/"), nil +} + +func envEnabled(name string) bool { + return strings.TrimSpace(strings.ToLower(os.Getenv(name))) == "1" || + strings.TrimSpace(strings.ToLower(os.Getenv(name))) == "true" || + strings.TrimSpace(strings.ToLower(os.Getenv(name))) == "yes" || + strings.TrimSpace(strings.ToLower(os.Getenv(name))) == "on" +} + +func isLoopbackHost(host string) bool { + normalized := strings.TrimSpace(strings.ToLower(host)) + if normalized == "localhost" { + return true + } + ip := net.ParseIP(normalized) + return ip != nil && ip.IsLoopback() +} + +func keyserverToken(baseURL, flagValue string) string { + if strings.TrimSpace(flagValue) != "" { + return strings.TrimSpace(flagValue) + } + if token := strings.TrimSpace(os.Getenv(keyserverTokenEnv)); token != "" { + return token + } + token, _ := loadStoredKeyserverToken(baseURL) + return token +} + +func keyserverHTTPClient() *http.Client { + return keyserverClient +} + +func localKeyserverProfile(name string) (*keyserverProfile, string, error) { + store, err := keyring.Load() + if err != nil { + return nil, "", err + } + name = strings.TrimSpace(name) + if name == "" { + name = store.DefaultSigner() + } + if name == "" { + return nil, "", fmt.Errorf("--name is required because no default signer is configured") + } + entry, ok := store.Key(name) + if !ok { + return nil, "", fmt.Errorf("unknown local key: %s", name) + } + share, err := tutorialShareFromEntry(entry) + if err != nil { + return nil, "", err + } + payload, err := decodeShareToken(share) + if err != nil { + return nil, "", err + } + return &keyserverProfile{ + ID: payload.ID, + Recipient: payload.Recipient, + SigningPublic: payload.SigningPublic, + DisplayName: payload.ID, + }, share, nil +} + +func publishKeyserverProfile(baseURL, token string, profile *keyserverProfile) error { + body, err := json.Marshal(profile) + if err != nil { + return fmt.Errorf("encode keyserver profile: %w", err) + } + req, err := http.NewRequest(http.MethodPut, baseURL+"/v1/peers/"+url.PathEscape(profile.ID), bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := keyserverHTTPClient().Do(req) + if err != nil { + return fmt.Errorf("publish profile: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("publish profile failed: %s: %s", resp.Status, readErrorBody(resp.Body)) + } + return nil +} + +func exchangeProviderToken(baseURL, provider, accessToken, peerID string) (*keyserverAuthResponse, error) { + body, err := json.Marshal(map[string]string{ + "access_token": accessToken, + "peer_id": peerID, + }) + if err != nil { + return nil, fmt.Errorf("encode auth request: %w", err) + } + req, err := http.NewRequest(http.MethodPost, baseURL+"/v1/auth/"+url.PathEscape(provider), bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := keyserverHTTPClient().Do(req) + if err != nil { + return nil, fmt.Errorf("exchange SSO token: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("exchange SSO token failed: %s: %s", resp.Status, readErrorBody(resp.Body)) + } + var auth keyserverAuthResponse + if err := json.NewDecoder(resp.Body).Decode(&auth); err != nil { + return nil, fmt.Errorf("parse auth response: %w", err) + } + if strings.TrimSpace(auth.Token) == "" { + return nil, fmt.Errorf("keyserver auth response did not include a token") + } + return &auth, nil +} + +func fetchKeyserverShare(baseURL, peerID string) (string, *sharePayload, error) { + peerID = strings.TrimSpace(peerID) + if peerID == "" { + return "", nil, fmt.Errorf("peer id is required") + } + resp, err := keyserverHTTPClient().Get(baseURL + "/v1/share/" + url.PathEscape(peerID)) + if err != nil { + return "", nil, fmt.Errorf("fetch peer from keyserver: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", nil, fmt.Errorf("fetch peer failed: %s: %s", resp.Status, readErrorBody(resp.Body)) + } + var body keyserverShareResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", nil, fmt.Errorf("parse keyserver response: %w", err) + } + payload, err := decodeShareToken(body.ShareCode) + if err != nil { + return "", nil, err + } + if _, err := age.ParseX25519Recipient(payload.Recipient); err != nil { + return "", nil, fmt.Errorf("invalid recipient in keyserver share code: %w", err) + } + if _, err := sign.ParsePublicKey(payload.SigningPublic); err != nil { + return "", nil, fmt.Errorf("invalid signing public key in keyserver share code: %w", err) + } + return body.ShareCode, payload, nil +} + +func addPeerFromSharePayload(store *keyring.Store, alias string, payload *sharePayload, force bool) error { + if strings.TrimSpace(alias) == "" { + alias = payload.ID + } + if strings.TrimSpace(alias) == "" { + return fmt.Errorf("alias is required") + } + if err := store.AddRecipient(alias, payload.Recipient, keyserverSource, payload.ID, force); err != nil { + return err + } + if err := store.AddSender(alias, payload.SigningPublic, keyserverSource, payload.ID, force); err != nil { + return err + } + return store.Save() +} + +func readErrorBody(r io.Reader) string { + b, _ := io.ReadAll(io.LimitReader(r, 4096)) + msg := strings.TrimSpace(string(b)) + if msg == "" { + return "empty response body" + } + return msg +} + +func storeKeyserverToken(baseURL, token string) error { + path, err := keyserverTokenPath() + if err != nil { + return err + } + tf := keyserverTokenFile{Tokens: map[string]string{}} + if b, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(b, &tf) + } + if tf.Tokens == nil { + tf.Tokens = map[string]string{} + } + tf.Tokens[baseURL] = token + b, err := json.MarshalIndent(tf, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + return os.WriteFile(path, append(b, '\n'), 0o600) +} + +func loadStoredKeyserverToken(baseURL string) (string, error) { + path, err := keyserverTokenPath() + if err != nil { + return "", err + } + b, err := os.ReadFile(path) + if err != nil { + return "", err + } + var tf keyserverTokenFile + if err := json.Unmarshal(b, &tf); err != nil { + return "", err + } + return strings.TrimSpace(tf.Tokens[baseURL]), nil +} + +func keyserverTokenPath() (string, error) { + configDir, _, _, err := keyring.DefaultPaths() + if err != nil { + return "", err + } + return filepath.Join(configDir, "keyserver-tokens.json"), nil +} diff --git a/cmd/ende/keyserver_cmd.go b/cmd/ende/keyserver_cmd.go new file mode 100644 index 0000000..8422b71 --- /dev/null +++ b/cmd/ende/keyserver_cmd.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newKeyserverCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "keyserver", + Short: "Publish local public keys to an optional keyserver", + } + cmd.AddCommand(newKeyserverLoginCommand(), newKeyserverPublishCommand()) + return cmd +} + +func newKeyserverPublishCommand() *cobra.Command { + var name string + var serverURL string + var token string + var insecureHTTP bool + cmd := &cobra.Command{ + Use: "publish", + Short: "Publish your public profile to a keyserver", + RunE: func(cmd *cobra.Command, args []string) error { + baseURL, err := keyserverURL(serverURL, insecureHTTP) + if err != nil { + return err + } + profile, share, err := localKeyserverProfile(name) + if err != nil { + return err + } + if err := publishKeyserverProfile(baseURL, keyserverToken(baseURL, token), profile); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "published %s to %s\n", profile.ID, baseURL) + fmt.Fprintf(cmd.OutOrStdout(), "share: %s\n", share) + return nil + }, + } + cmd.Flags().StringVar(&name, "name", "", "local key id (defaults to the default signer)") + cmd.Flags().StringVar(&serverURL, "keyserver", "", "keyserver base URL (or ENDE_KEYSERVER_URL)") + cmd.Flags().StringVar(&token, "token", "", "bearer token for keyserver writes (or ENDE_KEYSERVER_TOKEN)") + cmd.Flags().BoolVar(&insecureHTTP, "insecure-http", false, "allow non-local HTTP keyserver URLs for testing") + return cmd +} diff --git a/cmd/ende/keyserver_cmd_test.go b/cmd/ende/keyserver_cmd_test.go new file mode 100644 index 0000000..b3b2c15 --- /dev/null +++ b/cmd/ende/keyserver_cmd_test.go @@ -0,0 +1,280 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "filippo.io/age" + "github.com/kuma/ende/internal/keyring" + "github.com/kuma/ende/internal/sign" +) + +func TestKeyserverPublishCommandPublishesLocalProfile(t *testing.T) { + t.Setenv("ENDE_CONFIG_DIR", t.TempDir()) + if _, err := tutorialKeygen("alice"); err != nil { + t.Fatalf("keygen: %v", err) + } + + var gotAuth string + var gotProfile keyserverProfile + withKeyserverTransport(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodPut { + t.Fatalf("expected PUT, got %s", r.Method) + } + if r.URL.String() != "https://keys.example.test/v1/peers/alice" { + t.Fatalf("unexpected URL: %s", r.URL.String()) + } + gotAuth = r.Header.Get("Authorization") + if err := json.NewDecoder(r.Body).Decode(&gotProfile); err != nil { + t.Fatalf("decode request: %v", err) + } + return jsonResponse(http.StatusOK, `{}`) + })) + + cmd := newKeyserverPublishCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetArgs([]string{"--name", "alice", "--keyserver", "https://keys.example.test", "--token", "secret"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("publish failed: %v", err) + } + if gotAuth != "Bearer secret" { + t.Fatalf("unexpected auth header: %q", gotAuth) + } + if gotProfile.ID != "alice" || gotProfile.Recipient == "" || gotProfile.SigningPublic == "" { + t.Fatalf("unexpected published profile: %+v", gotProfile) + } + if !strings.Contains(out.String(), "published alice") { + t.Fatalf("expected publish output, got:\n%s", out.String()) + } +} + +func TestPeerFetchCommandPinsKeyserverPeer(t *testing.T) { + t.Setenv("ENDE_CONFIG_DIR", t.TempDir()) + share := testShareToken(t, "bob") + withKeyserverTransport(t, shareTransport(t, &share)) + + cmd := newPeerFetchCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetArgs([]string{"bob", "--keyserver", "https://keys.example.test"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("fetch failed: %v", err) + } + + store, err := keyring.Load() + if err != nil { + t.Fatalf("load keyring: %v", err) + } + if _, ok := store.Recipient("bob"); !ok { + t.Fatal("expected bob recipient") + } + if _, ok := store.Sender("bob"); !ok { + t.Fatal("expected bob trusted sender") + } + if !strings.Contains(out.String(), "fetched and pinned peer bob") { + t.Fatalf("expected fetch output, got:\n%s", out.String()) + } +} + +func TestPeerRefreshCommandRequiresYesBeforeUpdatingChangedPin(t *testing.T) { + t.Setenv("ENDE_CONFIG_DIR", t.TempDir()) + share := testShareToken(t, "bob") + withKeyserverTransport(t, shareTransport(t, &share)) + + fetch := newPeerFetchCommand() + fetch.SetArgs([]string{"bob", "--keyserver", "https://keys.example.test"}) + if err := fetch.Execute(); err != nil { + t.Fatalf("fetch failed: %v", err) + } + + share = testShareToken(t, "bob") + + refresh := newPeerRefreshCommand() + refresh.SetArgs([]string{"bob", "--keyserver", "https://keys.example.test"}) + if err := refresh.Execute(); err == nil { + t.Fatal("expected refresh without --yes to refuse changed pin") + } + + refresh = newPeerRefreshCommand() + var out bytes.Buffer + refresh.SetOut(&out) + refresh.SetArgs([]string{"bob", "--keyserver", "https://keys.example.test", "--yes"}) + if err := refresh.Execute(); err != nil { + t.Fatalf("refresh with --yes failed: %v", err) + } + if !strings.Contains(out.String(), "refreshed pinned peer bob") { + t.Fatalf("expected refresh output, got:\n%s", out.String()) + } +} + +func TestKeyserverLoginCommandStoresPerUserToken(t *testing.T) { + t.Setenv("ENDE_CONFIG_DIR", t.TempDir()) + withKeyserverTransport(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.String() == "https://keys.example.test/v1/auth/config" { + return jsonResponse(http.StatusOK, `{"default_provider":"github","providers":{"github":{"client_id":"configured-client"}}}`) + } + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.URL.String() != "https://keys.example.test/v1/auth/github" { + t.Fatalf("unexpected URL: %s", r.URL.String()) + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode login request: %v", err) + } + if body["access_token"] != "provider-token" || body["peer_id"] != "alice" { + t.Fatalf("unexpected login body: %+v", body) + } + return jsonResponse(http.StatusOK, `{"token":"ende_ks_user","peer_id":"alice","provider":"github","subject":"alice"}`) + })) + + cmd := newKeyserverLoginCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetArgs([]string{ + "--name", "alice", + "--keyserver", "https://keys.example.test", + "--access-token", "provider-token", + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("login failed: %v", err) + } + token, err := loadStoredKeyserverToken("https://keys.example.test") + if err != nil { + t.Fatalf("load stored token: %v", err) + } + if token != "ende_ks_user" { + t.Fatalf("unexpected stored token: %q", token) + } + if !strings.Contains(out.String(), "logged in to https://keys.example.test as alice via github") { + t.Fatalf("unexpected login output:\n%s", out.String()) + } +} + +func TestKeyserverLoginUsesAuthConfigForDeviceFlow(t *testing.T) { + t.Setenv("ENDE_CONFIG_DIR", t.TempDir()) + requests := []string{} + withKeyserverTransport(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + requests = append(requests, r.URL.String()) + switch r.URL.String() { + case "https://keys.example.test/v1/auth/config": + return jsonResponse(http.StatusOK, `{"default_provider":"github","providers":{"github":{"client_id":"configured-client"}}}`) + case "https://github.com/login/device/code": + if err := r.ParseForm(); err != nil { + t.Fatalf("parse device form: %v", err) + } + if r.Form.Get("client_id") != "configured-client" { + t.Fatalf("expected configured client id, got %q", r.Form.Get("client_id")) + } + return jsonResponse(http.StatusOK, `{"device_code":"device","user_code":"ABCD","verification_uri":"https://github.com/login/device","interval":0,"expires_in":10}`) + case "https://github.com/login/oauth/access_token": + return jsonResponse(http.StatusOK, `{"access_token":"provider-token"}`) + case "https://keys.example.test/v1/auth/github": + return jsonResponse(http.StatusOK, `{"token":"ende_ks_user","peer_id":"alice","provider":"github","subject":"alice"}`) + default: + t.Fatalf("unexpected request: %s", r.URL.String()) + } + return nil, nil + })) + + cmd := newKeyserverLoginCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetArgs([]string{"--name", "alice", "--keyserver", "https://keys.example.test"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("login failed: %v; requests=%v", err, requests) + } + if !strings.Contains(out.String(), "Open: https://github.com/login/device") { + t.Fatalf("expected device instructions, got:\n%s", out.String()) + } +} + +func TestKeyserverURLRequiresHTTPSForRemoteHosts(t *testing.T) { + _, err := keyserverURL("http://keys.example.test") + if err == nil { + t.Fatal("expected remote HTTP keyserver URL to be rejected") + } + if !strings.Contains(err.Error(), "https://") { + t.Fatalf("expected HTTPS error, got %v", err) + } +} + +func TestKeyserverURLAllowsLocalHTTP(t *testing.T) { + got, err := keyserverURL("http://127.0.0.1:8765/") + if err != nil { + t.Fatalf("expected local HTTP keyserver URL to pass: %v", err) + } + if got != "http://127.0.0.1:8765" { + t.Fatalf("unexpected normalized URL: %q", got) + } +} + +func TestKeyserverURLAllowsExplicitInsecureHTTP(t *testing.T) { + got, err := keyserverURL("http://keyserver:8765", true) + if err != nil { + t.Fatalf("expected explicit insecure HTTP override to pass: %v", err) + } + if got != "http://keyserver:8765" { + t.Fatalf("unexpected normalized URL: %q", got) + } +} + +func testShareToken(t *testing.T, id string) string { + t.Helper() + xid, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("generate age identity: %v", err) + } + signPub, _, err := sign.GenerateKeyPair() + if err != nil { + t.Fatalf("generate signing key: %v", err) + } + share, err := encodeShareToken(id, xid.Recipient().String(), signPub) + if err != nil { + t.Fatalf("encode share token: %v", err) + } + return share +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return fn(r) +} + +func withKeyserverTransport(t *testing.T, transport http.RoundTripper) { + t.Helper() + original := keyserverClient + keyserverClient = &http.Client{Transport: transport} + t.Cleanup(func() { + keyserverClient = original + }) +} + +func shareTransport(t *testing.T, share *string) http.RoundTripper { + t.Helper() + return roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodGet { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.String() != "https://keys.example.test/v1/share/bob" { + t.Fatalf("unexpected URL: %s", r.URL.String()) + } + return jsonResponse(http.StatusOK, `{"share_code":"`+*share+`"}`) + }) +} + +func jsonResponse(status int, body string) (*http.Response, error) { + return &http.Response{ + StatusCode: status, + Status: http.StatusText(status), + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil +} diff --git a/cmd/ende/keyserver_login_cmd.go b/cmd/ende/keyserver_login_cmd.go new file mode 100644 index 0000000..a9d78cb --- /dev/null +++ b/cmd/ende/keyserver_login_cmd.go @@ -0,0 +1,227 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/kuma/ende/internal/keyring" + "github.com/spf13/cobra" +) + +type deviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + Interval int `json:"interval"` + ExpiresIn int `json:"expires_in"` +} + +type deviceTokenResponse struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` +} + +type keyserverAuthConfig struct { + DefaultProvider string `json:"default_provider"` + Providers map[string]keyserverProviderConfig `json:"providers"` +} + +type keyserverProviderConfig struct { + ClientID string `json:"client_id"` + Issuer string `json:"issuer"` +} + +func newKeyserverLoginCommand() *cobra.Command { + var provider string + var serverURL string + var name string + var clientID string + var issuer string + var accessToken string + var insecureHTTP bool + cmd := &cobra.Command{ + Use: "login", + Short: "Login with GitHub or Okta and store a per-user publish token", + RunE: func(cmd *cobra.Command, args []string) error { + baseURL, err := keyserverURL(serverURL, insecureHTTP) + if err != nil { + return err + } + cfg, _ := fetchKeyserverAuthConfig(baseURL) + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" && cfg != nil { + provider = cfg.DefaultProvider + } + if provider == "" { + provider = "github" + } + if cfg != nil { + if p, ok := cfg.Providers[provider]; ok { + if strings.TrimSpace(clientID) == "" { + clientID = p.ClientID + } + if strings.TrimSpace(issuer) == "" { + issuer = p.Issuer + } + } + } + if strings.TrimSpace(name) == "" { + store, err := keyring.Load() + if err != nil { + return err + } + name = store.DefaultSigner() + } + if strings.TrimSpace(name) == "" { + return fmt.Errorf("--name is required because no default signer is configured") + } + + if strings.TrimSpace(accessToken) == "" { + token, err := runDeviceLogin(cmd.OutOrStdout(), provider, clientID, issuer) + if err != nil { + return err + } + accessToken = token + } + auth, err := exchangeProviderToken(baseURL, provider, accessToken, name) + if err != nil { + return err + } + if err := storeKeyserverToken(baseURL, auth.Token); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "logged in to %s as %s via %s\n", baseURL, auth.PeerID, auth.Provider) + fmt.Fprintf(cmd.OutOrStdout(), "stored publish token for %s\n", baseURL) + return nil + }, + } + cmd.Flags().StringVar(&provider, "provider", "", "SSO provider: github or okta (defaults to keyserver config)") + cmd.Flags().StringVar(&serverURL, "keyserver", "", "keyserver base URL (or ENDE_KEYSERVER_URL)") + cmd.Flags().StringVar(&name, "name", "", "local peer id to bind to the publish token") + cmd.Flags().StringVar(&clientID, "client-id", "", "OAuth device-flow client id") + cmd.Flags().StringVar(&issuer, "issuer", "", "Okta issuer URL, e.g. https://example.okta.com/oauth2/default") + cmd.Flags().StringVar(&accessToken, "access-token", "", "provider access token for non-interactive login/testing") + cmd.Flags().BoolVar(&insecureHTTP, "insecure-http", false, "allow non-local HTTP keyserver URLs for testing") + return cmd +} + +func runDeviceLogin(out interface{ Write([]byte) (int, error) }, provider, clientID, issuer string) (string, error) { + deviceEndpoint, tokenEndpoint, scope, err := deviceFlowConfig(provider, clientID, issuer) + if err != nil { + return "", err + } + form := url.Values{} + form.Set("client_id", clientID) + form.Set("scope", scope) + device, err := postFormJSON[deviceCodeResponse](deviceEndpoint, form) + if err != nil { + return "", err + } + if device.Interval <= 0 { + device.Interval = 5 + } + verification := device.VerificationURIComplete + if verification == "" { + verification = device.VerificationURI + } + fmt.Fprintf(out, "Open: %s\n", verification) + if device.UserCode != "" { + fmt.Fprintf(out, "Code: %s\n", device.UserCode) + } + + deadline := time.Now().Add(time.Duration(device.ExpiresIn) * time.Second) + for time.Now().Before(deadline) { + time.Sleep(time.Duration(device.Interval) * time.Second) + form = url.Values{} + form.Set("client_id", clientID) + form.Set("device_code", device.DeviceCode) + form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + token, err := postFormJSON[deviceTokenResponse](tokenEndpoint, form) + if err != nil { + return "", err + } + if token.AccessToken != "" { + return token.AccessToken, nil + } + if token.Error == "authorization_pending" { + continue + } + if token.Error == "slow_down" { + device.Interval++ + continue + } + if token.Error != "" { + if token.ErrorDesc != "" { + return "", fmt.Errorf("%s: %s", token.Error, token.ErrorDesc) + } + return "", errors.New(token.Error) + } + } + return "", fmt.Errorf("device login expired") +} + +func fetchKeyserverAuthConfig(baseURL string) (*keyserverAuthConfig, error) { + resp, err := keyserverHTTPClient().Get(baseURL + "/v1/auth/config") + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("fetch auth config failed: %s", resp.Status) + } + var cfg keyserverAuthConfig + if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil { + return nil, err + } + if cfg.Providers == nil { + cfg.Providers = map[string]keyserverProviderConfig{} + } + return &cfg, nil +} + +func deviceFlowConfig(provider, clientID, issuer string) (deviceEndpoint, tokenEndpoint, scope string, err error) { + switch provider { + case "github": + if clientID == "" { + return "", "", "", fmt.Errorf("--client-id is required for GitHub login") + } + return "https://github.com/login/device/code", "https://github.com/login/oauth/access_token", "read:user user:email", nil + case "okta": + if clientID == "" { + return "", "", "", fmt.Errorf("--client-id is required for Okta login") + } + issuer = strings.TrimRight(strings.TrimSpace(issuer), "/") + if issuer == "" { + return "", "", "", fmt.Errorf("--issuer is required for Okta login") + } + return issuer + "/v1/device/authorize", issuer + "/v1/token", "openid profile email", nil + default: + return "", "", "", fmt.Errorf("unsupported provider: %s", provider) + } +} + +func postFormJSON[T any](endpoint string, form url.Values) (*T, error) { + req, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + resp, err := keyserverHTTPClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var out T + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/cmd/ende/peer_cmd.go b/cmd/ende/peer_cmd.go new file mode 100644 index 0000000..906f56e --- /dev/null +++ b/cmd/ende/peer_cmd.go @@ -0,0 +1,125 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/kuma/ende/internal/keyring" + "github.com/spf13/cobra" +) + +func newPeerCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "peer", + Short: "Manage peers", + Aliases: []string{"p"}, + } + cmd.AddCommand(newPeerFetchCommand(), newPeerRefreshCommand()) + return cmd +} + +func newPeerFetchCommand() *cobra.Command { + var serverURL string + var alias string + var force bool + var insecureHTTP bool + cmd := &cobra.Command{ + Use: "fetch ", + Short: "Fetch and pin a peer from a keyserver", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + baseURL, err := keyserverURL(serverURL, insecureHTTP) + if err != nil { + return err + } + _, payload, err := fetchKeyserverShare(baseURL, args[0]) + if err != nil { + return err + } + if strings.TrimSpace(alias) == "" { + alias = payload.ID + } + store, err := keyring.Load() + if err != nil { + return err + } + if err := addPeerFromSharePayload(store, alias, payload, force); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "fetched and pinned peer %s from %s\n", alias, baseURL) + fmt.Fprintf(cmd.OutOrStdout(), "recipient fingerprint: %s\n", short(keyring.FingerprintAgePublicKey(payload.Recipient))) + fmt.Fprintf(cmd.OutOrStdout(), "signing fingerprint: %s\n", short(keyring.FingerprintSignPublicKey(payload.SigningPublic))) + return nil + }, + } + cmd.Flags().StringVar(&serverURL, "keyserver", "", "keyserver base URL (or ENDE_KEYSERVER_URL)") + cmd.Flags().StringVar(&alias, "alias", "", "local peer alias (defaults to fetched peer id)") + cmd.Flags().BoolVar(&force, "force", false, "overwrite an existing peer alias") + cmd.Flags().BoolVar(&insecureHTTP, "insecure-http", false, "allow non-local HTTP keyserver URLs for testing") + return cmd +} + +func newPeerRefreshCommand() *cobra.Command { + var serverURL string + var remoteID string + var yes bool + var insecureHTTP bool + cmd := &cobra.Command{ + Use: "refresh ", + Short: "Compare a pinned peer with the keyserver and update with --yes", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + alias := strings.TrimSpace(args[0]) + baseURL, err := keyserverURL(serverURL, insecureHTTP) + if err != nil { + return err + } + store, err := keyring.Load() + if err != nil { + return err + } + currentRecipient, ok := store.Recipient(alias) + if !ok { + return fmt.Errorf("peer %s is not pinned locally; use `ende peer fetch %s` first", alias, alias) + } + currentSender, ok := store.Sender(alias) + if !ok { + return fmt.Errorf("peer %s has no trusted signing key; use `ende peer fetch %s --force` to repair it", alias, alias) + } + if strings.TrimSpace(remoteID) == "" { + remoteID = currentRecipient.Username + } + if strings.TrimSpace(remoteID) == "" { + remoteID = alias + } + _, payload, err := fetchKeyserverShare(baseURL, remoteID) + if err != nil { + return err + } + sameRecipient := strings.TrimSpace(currentRecipient.AgePublic) == strings.TrimSpace(payload.Recipient) + sameSigning := strings.TrimSpace(currentSender.SignPublic) == strings.TrimSpace(payload.SigningPublic) + if sameRecipient && sameSigning { + fmt.Fprintf(cmd.OutOrStdout(), "peer %s is up to date\n", alias) + return nil + } + + out := cmd.OutOrStdout() + fmt.Fprintf(out, "peer %s differs from keyserver profile %s\n", alias, payload.ID) + fmt.Fprintf(out, "recipient: %s -> %s\n", short(currentRecipient.Fingerprint), short(keyring.FingerprintAgePublicKey(payload.Recipient))) + fmt.Fprintf(out, "signing: %s -> %s\n", short(currentSender.Fingerprint), short(keyring.FingerprintSignPublicKey(payload.SigningPublic))) + if !yes { + return fmt.Errorf("refusing to update pinned peer without --yes") + } + if err := addPeerFromSharePayload(store, alias, payload, true); err != nil { + return err + } + fmt.Fprintf(out, "refreshed pinned peer %s from %s\n", alias, baseURL) + return nil + }, + } + cmd.Flags().StringVar(&serverURL, "keyserver", "", "keyserver base URL (or ENDE_KEYSERVER_URL)") + cmd.Flags().StringVar(&remoteID, "id", "", "keyserver peer id (defaults to the pinned source id or alias)") + cmd.Flags().BoolVar(&yes, "yes", false, "update the local pinned peer when keyserver values differ") + cmd.Flags().BoolVar(&insecureHTTP, "insecure-http", false, "allow non-local HTTP keyserver URLs for testing") + return cmd +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..02d6b1b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +services: + keyserver: + build: + context: . + dockerfile: keyserver/Dockerfile + environment: + ENDE_KEYSERVER_BACKEND: file + ENDE_KEYSERVER_FILE: /data/keyserver.json + ENDE_KEYSERVER_HOST: 0.0.0.0 + ENDE_KEYSERVER_PORT: "8765" + ENDE_KEYSERVER_REQUIRE_AUTH: "1" + ENDE_KEYSERVER_DEV_AUTH: "1" + ENDE_KEYSERVER_ALLOWED_PEERS: alice,bob + ENDE_KEYSERVER_DEFAULT_PROVIDER: github + ENDE_KEYSERVER_GITHUB_CLIENT_ID: compose-github-client + ENDE_KEYSERVER_OKTA_CLIENT_ID: compose-okta-client + ENDE_KEYSERVER_OKTA_ISSUER: https://okta.example.test/oauth2/default + ports: + - "8765:8765" + volumes: + - keyserver-data:/data + healthcheck: + test: ["CMD", "python3", "-c", "import json, urllib.request; json.load(urllib.request.urlopen('http://127.0.0.1:8765/health', timeout=2))"] + interval: 2s + timeout: 3s + retries: 20 + + smoke: + image: golang:1.25 + working_dir: /src + environment: + GOFLAGS: -mod=vendor + ENDE_KEYSERVER_URL: http://keyserver:8765 + ENDE_KEYSERVER_INSECURE_HTTP: "1" + volumes: + - .:/src + depends_on: + keyserver: + condition: service_healthy + command: ["sh", "scripts/keyserver-compose-smoke.sh"] + +volumes: + keyserver-data: diff --git a/keyserver/Dockerfile b/keyserver/Dockerfile new file mode 100644 index 0000000..f07584a --- /dev/null +++ b/keyserver/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY keyserver /app/keyserver + +ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 + +EXPOSE 8765 + +CMD ["python3", "-m", "keyserver.ende_keyserver"] diff --git a/keyserver/README.md b/keyserver/README.md new file mode 100644 index 0000000..2009424 --- /dev/null +++ b/keyserver/README.md @@ -0,0 +1,188 @@ +# Ende Keyserver + +Optional public key directory for Ende peer onboarding. + +The keyserver stores public peer profiles only. It does not store private keys, +does not decrypt envelopes, and should not replace Ende's local keyring pinning +model. Clients should still confirm and pin fetched keys locally. + +## API + +- `GET /health` +- `GET /v1/peers/{id}` +- `PUT /v1/peers/{id}` +- `DELETE /v1/peers/{id}` +- `GET /v1/share/{id}` + +`PUT` and `DELETE` require a bearer token when `ENDE_KEYSERVER_ADMIN_TOKEN` is set. + +Profile shape: + +```json +{ + "id": "alice", + "recipient": "age1...", + "signing_public": "base64-ed25519-public-key", + "display_name": "Alice", + "updated_at": "2026-05-04T00:00:00Z" +} +``` + +`GET /v1/share/{id}` returns a share-code compatible payload: + +```json +{ + "share_code": "ENDE-PUB-1:..." +} +``` + +## CLI Flow + +Publish your local public profile: + +```bash +export ENDE_KEYSERVER_URL=https://keys.example.com +ende keyserver login --name alice +ende keyserver publish --name alice +``` + +Okta login: + +```bash +ende keyserver login --provider okta --name alice +ende keyserver publish --name alice +``` + +Fetch and pin a peer: + +```bash +ende peer fetch bob +``` + +Refresh a pinned peer after a key rotation: + +```bash +ende peer refresh bob +ende peer refresh bob --yes +``` + +`refresh` prints the old and new fingerprints and refuses to overwrite local pins unless `--yes` is set. + +## SSO Token Issuing + +The CLI performs GitHub or Okta device login, sends the provider access token to +`POST /v1/auth/{provider}`, and stores the returned Ende publish token locally. +The keyserver stores only a SHA-256 hash of the Ende token. + +Supported auth endpoints: + +- `GET /v1/auth/config` +- `POST /v1/auth/github` +- `POST /v1/auth/okta` + +The CLI reads `GET /v1/auth/config` before device login, so users do not need to +pass OAuth client IDs manually. Configure provider metadata on the keyserver: + +```bash +ENDE_KEYSERVER_DEFAULT_PROVIDER=github +ENDE_KEYSERVER_GITHUB_CLIENT_ID= +ENDE_KEYSERVER_OKTA_CLIENT_ID= +ENDE_KEYSERVER_OKTA_ISSUER=https://example.okta.com/oauth2/default +``` + +Auth request shape: + +```json +{ + "access_token": "provider-access-token", + "peer_id": "alice" +} +``` + +Auth response shape: + +```json +{ + "token": "ende_ks_...", + "peer_id": "alice", + "provider": "github", + "subject": "alice" +} +``` + +Per-user tokens can publish only their own `peer_id`. Admin tokens can still +manage all peers. + +## Run Locally + +```bash +python3 -m keyserver.ende_keyserver +``` + +## Docker Compose Smoke Test + +From the repository root: + +```bash +make compose-test +make compose-down +``` + +The compose setup starts the Python keyserver and runs a Go CLI smoke test that: + +- initializes Alice and Bob +- logs Alice in with dev-mode GitHub auth +- logs Bob in with dev-mode Okta auth +- publishes both public profiles +- fetches and pins peers from the keyserver +- sends and receives a test secret + +`ENDE_KEYSERVER_DEV_AUTH=1` is enabled only in the compose test service so the +flow can run without real GitHub or Okta credentials. + +Default settings: + +- backend: `file` +- bind: `127.0.0.1` +- port: `8765` +- file path: `.ende-keyserver.json` + +## Configuration + +```bash +ENDE_KEYSERVER_BACKEND=file +ENDE_KEYSERVER_FILE=.ende-keyserver.json +ENDE_KEYSERVER_HOST=127.0.0.1 +ENDE_KEYSERVER_PORT=8765 +ENDE_KEYSERVER_ADMIN_TOKEN=change-me +ENDE_KEYSERVER_REQUIRE_AUTH=1 +ENDE_KEYSERVER_ALLOWED_PEERS=alice,bob +ENDE_KEYSERVER_IDENTITY_MAP=github:alice=alice,okta:alice@example.com=alice +ENDE_KEYSERVER_OKTA_ISSUER=https://example.okta.com/oauth2/default +``` + +## DynamoDB Backend + +DynamoDB is a good fit for this service because profiles are small documents +looked up by a single key. Use one table with a string partition key named `id`. + +```bash +ENDE_KEYSERVER_BACKEND=dynamodb +ENDE_KEYSERVER_DDB_TABLE=ende-peer-keys +ENDE_KEYSERVER_AWS_REGION=ap-northeast-2 +ENDE_KEYSERVER_ADMIN_TOKEN=change-me +python3 -m keyserver.ende_keyserver +``` + +Required item shape is the same as the profile JSON. The DynamoDB backend imports +`boto3` only when selected. + +## Security Notes + +- Treat this service as a public directory, not a trust root. +- Keep Ende's local keyring pinning as the final trust decision. +- Require TLS in production. +- The CLI rejects non-local `http://` keyserver URLs unless `--insecure-http` + or `ENDE_KEYSERVER_INSECURE_HTTP=1` is set for a test environment. +- Use `ENDE_KEYSERVER_ADMIN_TOKEN` or an API gateway authorizer for writes. +- Key rotation should show diffs to clients before local pins are updated. diff --git a/keyserver/ende_keyserver/__init__.py b/keyserver/ende_keyserver/__init__.py new file mode 100644 index 0000000..15049e8 --- /dev/null +++ b/keyserver/ende_keyserver/__init__.py @@ -0,0 +1,2 @@ +"""Optional Ende public key directory server.""" + diff --git a/keyserver/ende_keyserver/__main__.py b/keyserver/ende_keyserver/__main__.py new file mode 100644 index 0000000..5b9eec7 --- /dev/null +++ b/keyserver/ende_keyserver/__main__.py @@ -0,0 +1,5 @@ +from .server import main + + +if __name__ == "__main__": + main() diff --git a/keyserver/ende_keyserver/auth.py b/keyserver/ende_keyserver/auth.py new file mode 100644 index 0000000..a9b9ac2 --- /dev/null +++ b/keyserver/ende_keyserver/auth.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import json +import os +import urllib.request +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ProviderIdentity: + provider: str + subject: str + peer_id: str + email: str = "" + + +def verify_provider_token(provider: str, access_token: str, requested_peer_id: str = "") -> ProviderIdentity: + provider = provider.strip().lower() + if os.getenv("ENDE_KEYSERVER_DEV_AUTH", "").lower() in {"1", "true", "yes"}: + return _verify_dev(provider, access_token, requested_peer_id) + if provider == "github": + return _verify_github(access_token, requested_peer_id) + if provider == "okta": + return _verify_okta(access_token, requested_peer_id) + raise ValueError(f"unsupported auth provider: {provider}") + + +def _verify_dev(provider: str, access_token: str, requested_peer_id: str) -> ProviderIdentity: + subject = access_token.removeprefix("dev:").strip() + if not subject: + subject = requested_peer_id.strip() + if not subject: + raise ValueError("dev auth token must include a subject") + peer_id = _resolve_peer_id(provider, subject, "", requested_peer_id) + return ProviderIdentity(provider=provider, subject=subject, peer_id=peer_id) + + +def _verify_github(access_token: str, requested_peer_id: str) -> ProviderIdentity: + user = _get_json( + "https://api.github.com/user", + { + "Authorization": f"Bearer {access_token}", + "Accept": "application/vnd.github+json", + "User-Agent": "ende-keyserver", + }, + ) + login = str(user.get("login", "")).strip() + if not login: + raise ValueError("github token did not return a login") + email = str(user.get("email", "") or "").strip() + peer_id = _resolve_peer_id("github", login, email, requested_peer_id) + return ProviderIdentity(provider="github", subject=login, peer_id=peer_id, email=email) + + +def _verify_okta(access_token: str, requested_peer_id: str) -> ProviderIdentity: + issuer = os.getenv("ENDE_KEYSERVER_OKTA_ISSUER", "").strip().rstrip("/") + if not issuer: + raise ValueError("ENDE_KEYSERVER_OKTA_ISSUER is required for Okta SSO") + user = _get_json( + issuer + "/v1/userinfo", + { + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + }, + ) + subject = str(user.get("preferred_username") or user.get("email") or user.get("sub") or "").strip() + if not subject: + raise ValueError("okta token did not return a usable subject") + email = str(user.get("email", "") or "").strip() + peer_id = _resolve_peer_id("okta", subject, email, requested_peer_id) + return ProviderIdentity(provider="okta", subject=subject, peer_id=peer_id, email=email) + + +def _resolve_peer_id(provider: str, subject: str, email: str, requested_peer_id: str) -> str: + requested_peer_id = requested_peer_id.strip() + mapped = _mapped_peer_id(provider, subject, email) + peer_id = mapped or requested_peer_id or subject.split("@", 1)[0] + allowed = {v.strip() for v in os.getenv("ENDE_KEYSERVER_ALLOWED_PEERS", "").split(",") if v.strip()} + if allowed and peer_id not in allowed: + raise ValueError(f"peer id is not allowed: {peer_id}") + if requested_peer_id and mapped and requested_peer_id != mapped: + raise ValueError("requested peer id does not match SSO identity mapping") + if requested_peer_id and not mapped and requested_peer_id != subject and requested_peer_id != subject.split("@", 1)[0]: + raise ValueError("requested peer id does not match SSO identity") + return peer_id + + +def _mapped_peer_id(provider: str, subject: str, email: str) -> str: + raw = os.getenv("ENDE_KEYSERVER_IDENTITY_MAP", "") + keys = {f"{provider}:{subject}", subject} + if email: + keys.add(email) + keys.add(f"{provider}:{email}") + for item in raw.split(","): + if "=" not in item: + continue + left, right = item.split("=", 1) + if left.strip() in keys: + return right.strip() + return "" + + +def _get_json(url: str, headers: dict[str, str]) -> dict: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=10) as resp: + body = resp.read() + data = json.loads(body.decode("utf-8")) + if not isinstance(data, dict): + raise ValueError("provider response must be a JSON object") + return data diff --git a/keyserver/ende_keyserver/models.py b/keyserver/ende_keyserver/models.py new file mode 100644 index 0000000..8f38d68 --- /dev/null +++ b/keyserver/ende_keyserver/models.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import base64 +import hashlib +import re +import secrets +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + + +_ID_RE = re.compile(r"^[A-Za-z0-9._:@-]{1,128}$") +_SHARE_PREFIX = "ENDE-PUB-1:" + + +@dataclass(frozen=True) +class PeerProfile: + id: str + recipient: str + signing_public: str + display_name: str = "" + updated_at: str = "" + + @classmethod + def from_dict(cls, data: dict[str, Any], *, expected_id: str | None = None) -> "PeerProfile": + peer_id = str(data.get("id", "")).strip() + if expected_id is not None and not peer_id: + peer_id = expected_id + if expected_id is not None and peer_id != expected_id: + raise ValueError("profile id does not match request path") + if not _ID_RE.match(peer_id): + raise ValueError("id must be 1-128 chars and contain only letters, numbers, '.', '_', ':', '@', '-'") + + recipient = str(data.get("recipient", "")).strip() + if not recipient.startswith("age1"): + raise ValueError("recipient must be an age public key") + + signing_public = str(data.get("signing_public", "")).strip() + _validate_ed25519_public(signing_public) + + display_name = str(data.get("display_name", "")).strip() + updated_at = str(data.get("updated_at", "")).strip() or _now() + + return cls( + id=peer_id, + recipient=recipient, + signing_public=signing_public, + display_name=display_name, + updated_at=updated_at, + ) + + def to_dict(self) -> dict[str, str]: + data = { + "id": self.id, + "recipient": self.recipient, + "signing_public": self.signing_public, + "updated_at": self.updated_at, + } + if self.display_name: + data["display_name"] = self.display_name + return data + + def to_share_code(self) -> str: + import json + + payload = { + "version": 1, + "id": self.id, + "recipient": self.recipient, + "signing_public": self.signing_public, + } + encoded = base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode()).decode() + return _SHARE_PREFIX + encoded.rstrip("=") + + +@dataclass(frozen=True) +class TokenRecord: + token_hash: str + peer_id: str + provider: str + subject: str + email: str = "" + created_at: str = "" + revoked_at: str = "" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "TokenRecord": + return cls( + token_hash=str(data.get("token_hash", "")).strip(), + peer_id=str(data.get("peer_id", "")).strip(), + provider=str(data.get("provider", "")).strip(), + subject=str(data.get("subject", "")).strip(), + email=str(data.get("email", "")).strip(), + created_at=str(data.get("created_at", "")).strip(), + revoked_at=str(data.get("revoked_at", "")).strip(), + ) + + def to_dict(self) -> dict[str, str]: + data = { + "token_hash": self.token_hash, + "peer_id": self.peer_id, + "provider": self.provider, + "subject": self.subject, + "created_at": self.created_at, + } + if self.email: + data["email"] = self.email + if self.revoked_at: + data["revoked_at"] = self.revoked_at + return data + + def active(self) -> bool: + return self.revoked_at == "" + + +def new_publish_token() -> str: + return "ende_ks_" + secrets.token_urlsafe(32) + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.strip().encode("utf-8")).hexdigest() + + +def _validate_ed25519_public(value: str) -> None: + try: + raw = base64.b64decode(value, validate=True) + except Exception as exc: + raise ValueError("signing_public must be base64") from exc + if len(raw) != 32: + raise ValueError("signing_public must be a 32-byte Ed25519 public key") + + +def _now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") diff --git a/keyserver/ende_keyserver/server.py b/keyserver/ende_keyserver/server.py new file mode 100644 index 0000000..3ac38e9 --- /dev/null +++ b/keyserver/ende_keyserver/server.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import json +import os +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any +from urllib.parse import unquote, urlparse + +from .auth import verify_provider_token +from .models import PeerProfile, TokenRecord, _now, hash_token, new_publish_token +from .stores import Store, store_from_env + + +class KeyserverHandler(BaseHTTPRequestHandler): + store: Store + admin_token: str = "" + + def do_GET(self) -> None: + path = urlparse(self.path).path + if path == "/health": + self._json(HTTPStatus.OK, {"ok": True}) + return + if path == "/v1/auth/config": + self._json(HTTPStatus.OK, auth_config_from_env()) + return + if path.startswith("/v1/peers/"): + peer_id = _path_id(path, "/v1/peers/") + profile = self.store.get(peer_id) + if profile is None: + self._json(HTTPStatus.NOT_FOUND, {"error": "peer not found"}) + return + self._json(HTTPStatus.OK, profile.to_dict()) + return + if path.startswith("/v1/share/"): + peer_id = _path_id(path, "/v1/share/") + profile = self.store.get(peer_id) + if profile is None: + self._json(HTTPStatus.NOT_FOUND, {"error": "peer not found"}) + return + self._json(HTTPStatus.OK, {"share_code": profile.to_share_code()}) + return + self._json(HTTPStatus.NOT_FOUND, {"error": "not found"}) + + def do_PUT(self) -> None: + path = urlparse(self.path).path + if not path.startswith("/v1/peers/"): + self._json(HTTPStatus.NOT_FOUND, {"error": "not found"}) + return + peer_id = _path_id(path, "/v1/peers/") + if not self._authorized_peer_write(peer_id): + return + try: + profile = PeerProfile.from_dict(self._read_json(), expected_id=peer_id) + except ValueError as exc: + self._json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) + return + self.store.put(profile) + self._json(HTTPStatus.OK, profile.to_dict()) + + def do_DELETE(self) -> None: + path = urlparse(self.path).path + if not path.startswith("/v1/peers/"): + self._json(HTTPStatus.NOT_FOUND, {"error": "not found"}) + return + if not self._authorized(): + return + deleted = self.store.delete(_path_id(path, "/v1/peers/")) + self._json(HTTPStatus.OK, {"deleted": deleted}) + + def do_POST(self) -> None: + path = urlparse(self.path).path + if path.startswith("/v1/auth/"): + self._handle_auth(_path_id(path, "/v1/auth/")) + return + self._json(HTTPStatus.NOT_FOUND, {"error": "not found"}) + + def log_message(self, fmt: str, *args: Any) -> None: + if os.getenv("ENDE_KEYSERVER_ACCESS_LOG", "").lower() in {"1", "true", "yes"}: + super().log_message(fmt, *args) + + def _authorized(self) -> bool: + if not self.admin_token: + return True + expected = f"Bearer {self.admin_token}" + if self.headers.get("Authorization") == expected: + return True + self._json(HTTPStatus.UNAUTHORIZED, {"error": "unauthorized"}) + return False + + def _authorized_peer_write(self, peer_id: str) -> bool: + auth = self.headers.get("Authorization", "") + if self.admin_token and auth == f"Bearer {self.admin_token}": + return True + if not auth.startswith("Bearer "): + if not self.admin_token and not os.getenv("ENDE_KEYSERVER_REQUIRE_AUTH", ""): + return True + self._json(HTTPStatus.UNAUTHORIZED, {"error": "unauthorized"}) + return False + record = self.store.get_token(hash_token(auth.removeprefix("Bearer "))) + if record is None or not record.active(): + self._json(HTTPStatus.UNAUTHORIZED, {"error": "unauthorized"}) + return False + if record.peer_id != peer_id: + self._json(HTTPStatus.FORBIDDEN, {"error": "token cannot publish this peer id"}) + return False + return True + + def _handle_auth(self, provider: str) -> None: + try: + body = self._read_json() + access_token = str(body.get("access_token", "")).strip() + peer_id = str(body.get("peer_id", "")).strip() + if not access_token: + raise ValueError("access_token is required") + identity = verify_provider_token(provider, access_token, peer_id) + token = new_publish_token() + record = TokenRecord( + token_hash=hash_token(token), + peer_id=identity.peer_id, + provider=identity.provider, + subject=identity.subject, + email=identity.email, + created_at=_now(), + ) + self.store.put_token(record) + except ValueError as exc: + self._json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) + return + self._json( + HTTPStatus.OK, + { + "token": token, + "peer_id": record.peer_id, + "provider": record.provider, + "subject": record.subject, + }, + ) + + def _read_json(self) -> dict[str, Any]: + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length) + data = json.loads(raw.decode("utf-8")) + if not isinstance(data, dict): + raise ValueError("request body must be a JSON object") + return data + + def _json(self, status: HTTPStatus, body: dict[str, Any]) -> None: + encoded = json.dumps(body, sort_keys=True).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + +def make_handler(store: Store, admin_token: str = "") -> type[KeyserverHandler]: + class Handler(KeyserverHandler): + pass + + Handler.store = store + Handler.admin_token = admin_token + return Handler + + +def main() -> None: + host = os.getenv("ENDE_KEYSERVER_HOST", "127.0.0.1") + port = int(os.getenv("ENDE_KEYSERVER_PORT", "8765")) + handler = make_handler(store_from_env(), os.getenv("ENDE_KEYSERVER_ADMIN_TOKEN", "")) + server = ThreadingHTTPServer((host, port), handler) + print(f"ende keyserver listening on http://{host}:{port}") + server.serve_forever() + + +def _path_id(path: str, prefix: str) -> str: + return unquote(path.removeprefix(prefix)).strip() + + +def auth_config_from_env() -> dict[str, Any]: + providers: dict[str, dict[str, str]] = {} + github_client_id = os.getenv("ENDE_KEYSERVER_GITHUB_CLIENT_ID", "").strip() + if github_client_id: + providers["github"] = {"client_id": github_client_id} + okta_client_id = os.getenv("ENDE_KEYSERVER_OKTA_CLIENT_ID", "").strip() + okta_issuer = os.getenv("ENDE_KEYSERVER_OKTA_ISSUER", "").strip().rstrip("/") + if okta_client_id and okta_issuer: + providers["okta"] = {"client_id": okta_client_id, "issuer": okta_issuer} + default_provider = os.getenv("ENDE_KEYSERVER_DEFAULT_PROVIDER", "").strip().lower() + if not default_provider and providers: + default_provider = next(iter(providers)) + return {"default_provider": default_provider, "providers": providers} diff --git a/keyserver/ende_keyserver/stores.py b/keyserver/ende_keyserver/stores.py new file mode 100644 index 0000000..a7a7c29 --- /dev/null +++ b/keyserver/ende_keyserver/stores.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import json +import os +import tempfile +from abc import ABC, abstractmethod +from pathlib import Path + +from .models import PeerProfile, TokenRecord + + +class Store(ABC): + @abstractmethod + def get(self, peer_id: str) -> PeerProfile | None: + raise NotImplementedError + + @abstractmethod + def put(self, profile: PeerProfile) -> None: + raise NotImplementedError + + @abstractmethod + def delete(self, peer_id: str) -> bool: + raise NotImplementedError + + @abstractmethod + def get_token(self, token_hash: str) -> TokenRecord | None: + raise NotImplementedError + + @abstractmethod + def put_token(self, token: TokenRecord) -> None: + raise NotImplementedError + + +class MemoryStore(Store): + def __init__(self) -> None: + self._profiles: dict[str, PeerProfile] = {} + self._tokens: dict[str, TokenRecord] = {} + + def get(self, peer_id: str) -> PeerProfile | None: + return self._profiles.get(peer_id) + + def put(self, profile: PeerProfile) -> None: + self._profiles[profile.id] = profile + + def delete(self, peer_id: str) -> bool: + return self._profiles.pop(peer_id, None) is not None + + def get_token(self, token_hash: str) -> TokenRecord | None: + return self._tokens.get(token_hash) + + def put_token(self, token: TokenRecord) -> None: + self._tokens[token.token_hash] = token + + +class FileStore(Store): + def __init__(self, path: str) -> None: + self.path = Path(path) + + def get(self, peer_id: str) -> PeerProfile | None: + raw = self._read_root()["profiles"].get(peer_id) + if raw is None: + return None + return PeerProfile.from_dict(raw, expected_id=peer_id) + + def put(self, profile: PeerProfile) -> None: + root = self._read_root() + root["profiles"][profile.id] = profile.to_dict() + self._write_root(root) + + def delete(self, peer_id: str) -> bool: + root = self._read_root() + if peer_id not in root["profiles"]: + return False + del root["profiles"][peer_id] + self._write_root(root) + return True + + def get_token(self, token_hash: str) -> TokenRecord | None: + raw = self._read_root()["tokens"].get(token_hash) + if raw is None: + return None + return TokenRecord.from_dict(raw) + + def put_token(self, token: TokenRecord) -> None: + root = self._read_root() + root["tokens"][token.token_hash] = token.to_dict() + self._write_root(root) + + def _read_root(self) -> dict[str, dict[str, dict[str, str]]]: + if not self.path.exists(): + return {"profiles": {}, "tokens": {}} + with self.path.open("r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + raise ValueError("file store root must be an object") + if "profiles" in data or "tokens" in data: + profiles = data.get("profiles", {}) + tokens = data.get("tokens", {}) + if not isinstance(profiles, dict) or not isinstance(tokens, dict): + raise ValueError("file store profiles/tokens must be objects") + return {"profiles": profiles, "tokens": tokens} + return {"profiles": data, "tokens": {}} + + def _write_root(self, data: dict[str, dict[str, dict[str, str]]]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(prefix=f".{self.path.name}.", dir=str(self.path.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, sort_keys=True) + f.write("\n") + os.replace(tmp, self.path) + finally: + if os.path.exists(tmp): + os.unlink(tmp) + + +class DynamoDBStore(Store): + def __init__(self, table_name: str, region_name: str | None = None) -> None: + try: + import boto3 + except ImportError as exc: + raise RuntimeError("boto3 is required for ENDE_KEYSERVER_BACKEND=dynamodb") from exc + self._table = boto3.resource("dynamodb", region_name=region_name).Table(table_name) + + def get(self, peer_id: str) -> PeerProfile | None: + item = self._table.get_item(Key={"id": peer_id}).get("Item") + if item is None: + return None + return PeerProfile.from_dict(dict(item), expected_id=peer_id) + + def put(self, profile: PeerProfile) -> None: + self._table.put_item(Item=profile.to_dict()) + + def delete(self, peer_id: str) -> bool: + result = self._table.delete_item(Key={"id": peer_id}, ReturnValues="ALL_OLD") + return "Attributes" in result + + def get_token(self, token_hash: str) -> TokenRecord | None: + item = self._table.get_item(Key={"id": f"token#{token_hash}"}).get("Item") + if item is None: + return None + return TokenRecord.from_dict(dict(item)) + + def put_token(self, token: TokenRecord) -> None: + item = token.to_dict() + item["id"] = f"token#{token.token_hash}" + item["kind"] = "token" + self._table.put_item(Item=item) + + +def store_from_env() -> Store: + backend = os.getenv("ENDE_KEYSERVER_BACKEND", "file").strip().lower() + if backend == "memory": + return MemoryStore() + if backend == "file": + return FileStore(os.getenv("ENDE_KEYSERVER_FILE", ".ende-keyserver.json")) + if backend == "dynamodb": + table = os.getenv("ENDE_KEYSERVER_DDB_TABLE", "").strip() + if not table: + raise ValueError("ENDE_KEYSERVER_DDB_TABLE is required for DynamoDB backend") + return DynamoDBStore(table, os.getenv("ENDE_KEYSERVER_AWS_REGION") or None) + raise ValueError(f"unsupported keyserver backend: {backend}") diff --git a/keyserver/tests/test_keyserver.py b/keyserver/tests/test_keyserver.py new file mode 100644 index 0000000..83e0909 --- /dev/null +++ b/keyserver/tests/test_keyserver.py @@ -0,0 +1,127 @@ +import base64 +import json +import unittest +from http import HTTPStatus +from http.client import HTTPConnection +from threading import Thread + +import keyserver.ende_keyserver.server as keyserver_server +from keyserver.ende_keyserver.auth import ProviderIdentity +from keyserver.ende_keyserver.server import make_handler +from keyserver.ende_keyserver.stores import MemoryStore + + +SIGNING_PUBLIC = base64.b64encode(b"a" * 32).decode() + + +class KeyserverTest(unittest.TestCase): + def setUp(self): + handler = make_handler(MemoryStore(), "secret") + from http.server import ThreadingHTTPServer + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + self.thread = Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.host, self.port = self.server.server_address + + def tearDown(self): + self.server.shutdown() + self.thread.join(timeout=2) + self.server.server_close() + + def request(self, method, path, body=None, token=None): + conn = HTTPConnection(self.host, self.port, timeout=5) + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + if body is not None: + body = json.dumps(body).encode() + headers["Content-Type"] = "application/json" + conn.request(method, path, body=body, headers=headers) + resp = conn.getresponse() + data = json.loads(resp.read().decode()) + conn.close() + return resp.status, data + + def test_put_get_and_share_code(self): + profile = { + "id": "alice", + "recipient": "age1example", + "signing_public": SIGNING_PUBLIC, + "display_name": "Alice", + } + status, body = self.request("PUT", "/v1/peers/alice", profile, token="secret") + self.assertEqual(status, HTTPStatus.OK) + self.assertEqual(body["id"], "alice") + + status, body = self.request("GET", "/v1/peers/alice") + self.assertEqual(status, HTTPStatus.OK) + self.assertEqual(body["recipient"], "age1example") + + status, body = self.request("GET", "/v1/share/alice") + self.assertEqual(status, HTTPStatus.OK) + self.assertTrue(body["share_code"].startswith("ENDE-PUB-1:")) + payload = body["share_code"].removeprefix("ENDE-PUB-1:") + payload += "=" * (-len(payload) % 4) + decoded = json.loads(base64.urlsafe_b64decode(payload).decode()) + self.assertEqual(decoded["version"], 1) + self.assertEqual(decoded["id"], "alice") + self.assertEqual(decoded["recipient"], "age1example") + self.assertEqual(decoded["signing_public"], SIGNING_PUBLIC) + + def test_write_requires_bearer_token(self): + profile = { + "id": "alice", + "recipient": "age1example", + "signing_public": SIGNING_PUBLIC, + } + status, body = self.request("PUT", "/v1/peers/alice", profile) + self.assertEqual(status, HTTPStatus.UNAUTHORIZED) + self.assertEqual(body["error"], "unauthorized") + + def test_invalid_profile_rejected(self): + status, body = self.request( + "PUT", + "/v1/peers/alice", + {"id": "bob", "recipient": "not-age", "signing_public": "nope"}, + token="secret", + ) + self.assertEqual(status, HTTPStatus.BAD_REQUEST) + self.assertIn("id", body["error"]) + + def test_sso_token_can_publish_only_own_peer_id(self): + original = keyserver_server.verify_provider_token + keyserver_server.verify_provider_token = lambda provider, token, peer_id: ProviderIdentity( + provider=provider, + subject="alice", + peer_id=peer_id, + email="alice@example.com", + ) + try: + status, body = self.request( + "POST", + "/v1/auth/github", + {"access_token": "provider-token", "peer_id": "alice"}, + ) + finally: + keyserver_server.verify_provider_token = original + self.assertEqual(status, HTTPStatus.OK) + token = body["token"] + + profile = { + "id": "alice", + "recipient": "age1example", + "signing_public": SIGNING_PUBLIC, + } + status, _ = self.request("PUT", "/v1/peers/alice", profile, token=token) + self.assertEqual(status, HTTPStatus.OK) + + bob = dict(profile) + bob["id"] = "bob" + status, body = self.request("PUT", "/v1/peers/bob", bob, token=token) + self.assertEqual(status, HTTPStatus.FORBIDDEN) + self.assertIn("token cannot publish", body["error"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/keyserver-compose-smoke.sh b/scripts/keyserver-compose-smoke.sh new file mode 100644 index 0000000..5dda1c5 --- /dev/null +++ b/scripts/keyserver-compose-smoke.sh @@ -0,0 +1,26 @@ +set -eu + +go build -o /tmp/ende ./cmd/ende + +ENDE=/tmp/ende +KEYSERVER="${ENDE_KEYSERVER_URL:-http://keyserver:8765}" + +rm -rf /tmp/ende-compose +mkdir -p /tmp/ende-compose/alice /tmp/ende-compose/bob + +ENDE_CONFIG_DIR=/tmp/ende-compose/alice "$ENDE" init --name alice --keyserver "$KEYSERVER" +ENDE_CONFIG_DIR=/tmp/ende-compose/alice "$ENDE" keyserver login --name alice --keyserver "$KEYSERVER" --access-token dev:alice +ENDE_CONFIG_DIR=/tmp/ende-compose/alice "$ENDE" keyserver publish --name alice --keyserver "$KEYSERVER" + +ENDE_CONFIG_DIR=/tmp/ende-compose/bob "$ENDE" init --name bob --keyserver "$KEYSERVER" +ENDE_CONFIG_DIR=/tmp/ende-compose/bob "$ENDE" keyserver login --provider okta --name bob --keyserver "$KEYSERVER" --access-token dev:bob +ENDE_CONFIG_DIR=/tmp/ende-compose/bob "$ENDE" keyserver publish --name bob --keyserver "$KEYSERVER" + +ENDE_CONFIG_DIR=/tmp/ende-compose/alice "$ENDE" peer fetch bob --keyserver "$KEYSERVER" +ENDE_CONFIG_DIR=/tmp/ende-compose/bob "$ENDE" peer fetch alice --keyserver "$KEYSERVER" + +printf 'TOKEN=compose-smoke\n' | ENDE_CONFIG_DIR=/tmp/ende-compose/alice "$ENDE" send -t bob --sign-as alice -o /tmp/ende-compose/secret.txt +ENDE_CONFIG_DIR=/tmp/ende-compose/bob "$ENDE" receive -i /tmp/ende-compose/secret.txt -o /tmp/ende-compose/plaintext.txt + +grep 'TOKEN=compose-smoke' /tmp/ende-compose/plaintext.txt +echo "compose smoke test passed"