From a81e5d17c544f8b4ec7b65d47b16a96f82bdf84a Mon Sep 17 00:00:00 2001 From: evandance <120630830+evandance@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:29:47 +0800 Subject: [PATCH] feat(config): bind OpenClaw keyless credentials --- .github/workflows/release.yml | 19 + .goreleaser.yml | 46 +- cmd/auth/login.go | 18 +- cmd/auth/login_test.go | 29 +- cmd/config/bind.go | 234 ++++- cmd/config/bind_test.go | 21 +- cmd/config/binder.go | 84 +- cmd/config/binder_test.go | 45 +- cmd/config/config_test.go | 139 ++- cmd/config/init.go | 291 ++++-- cmd/config/init_auth_method_test.go | 306 +++++++ cmd/config/init_interactive.go | 234 ++++- cmd/config/init_probe.go | 33 + cmd/config/init_probe_test.go | 98 +- cmd/config/init_test.go | 71 ++ cmd/config/keyless_bind.go | 86 ++ cmd/config/keyless_bind_test.go | 438 +++++++++ cmd/doctor/doctor.go | 159 +++- cmd/doctor/doctor_test.go | 103 +++ go.mod | 16 + go.sum | 37 + internal/auth/app_registration.go | 194 +++- internal/auth/app_registration_test.go | 306 ++++++- internal/auth/client_auth.go | 124 +++ internal/auth/client_auth_test.go | 227 +++++ internal/auth/device_flow.go | 34 +- internal/auth/device_flow_test.go | 66 +- internal/auth/jwt/jwt.go | 152 ++++ internal/auth/jwt/jwt_test.go | 254 ++++++ internal/auth/uat_client.go | 49 +- internal/auth/uat_client_options_test.go | 24 + internal/auth/uat_client_test.go | 122 +++ internal/binding/keyless_types_test.go | 112 +++ internal/binding/types.go | 88 +- internal/core/config.go | 82 +- internal/core/config_test.go | 131 +++ internal/core/secret.go | 6 + internal/core/types.go | 7 + internal/core/types_test.go | 9 + internal/credential/default_provider.go | 13 + internal/credential/tat_fetch.go | 122 +++ internal/credential/tat_fetch_test.go | 145 +++ internal/credential/types.go | 9 + internal/credential/types_test.go | 7 +- internal/identitydiag/diagnostics.go | 4 +- internal/keylesshelper/helper.go | 287 ++++++ internal/keylesshelper/helper_test.go | 91 ++ .../keylessprovider/inspect_command_unix.go | 17 + .../inspect_command_windows.go | 49 + .../inspect_command_windows_test.go | 54 ++ internal/keylessprovider/manifest.go | 155 ++++ internal/keylessprovider/manifest_test.go | 493 ++++++++++ internal/keylessprovider/provider.go | 858 ++++++++++++++++++ internal/keylessprovider/provider_test.go | 537 +++++++++++ internal/keylessprovider/security_unix.go | 63 ++ internal/keylessprovider/security_windows.go | 149 +++ internal/keysigner/keysigner.go | 210 +++++ internal/keysigner/keysigner_test.go | 240 +++++ internal/keysigner/registry.go | 29 + internal/keysigner/signer_keychain_darwin.go | 760 ++++++++++++++++ .../keysigner/signer_keychain_darwin_test.go | 282 ++++++ internal/keysigner/signer_sks.go | 135 +++ internal/keysigner/signer_sks_test.go | 122 +++ release_config_test.go | 49 + scripts/build-pkg-pr-new.sh | 4 + .../server-multi-tenant-demo/auth_bridge.go | 4 +- 66 files changed, 9154 insertions(+), 228 deletions(-) create mode 100644 cmd/config/init_auth_method_test.go create mode 100644 cmd/config/keyless_bind.go create mode 100644 cmd/config/keyless_bind_test.go create mode 100644 internal/auth/client_auth.go create mode 100644 internal/auth/client_auth_test.go create mode 100644 internal/auth/jwt/jwt.go create mode 100644 internal/auth/jwt/jwt_test.go create mode 100644 internal/auth/uat_client_test.go create mode 100644 internal/binding/keyless_types_test.go create mode 100644 internal/keylesshelper/helper.go create mode 100644 internal/keylesshelper/helper_test.go create mode 100644 internal/keylessprovider/inspect_command_unix.go create mode 100644 internal/keylessprovider/inspect_command_windows.go create mode 100644 internal/keylessprovider/inspect_command_windows_test.go create mode 100644 internal/keylessprovider/manifest.go create mode 100644 internal/keylessprovider/manifest_test.go create mode 100644 internal/keylessprovider/provider.go create mode 100644 internal/keylessprovider/provider_test.go create mode 100644 internal/keylessprovider/security_unix.go create mode 100644 internal/keylessprovider/security_windows.go create mode 100644 internal/keysigner/keysigner.go create mode 100644 internal/keysigner/keysigner_test.go create mode 100644 internal/keysigner/registry.go create mode 100644 internal/keysigner/signer_keychain_darwin.go create mode 100644 internal/keysigner/signer_keychain_darwin_test.go create mode 100644 internal/keysigner/signer_sks.go create mode 100644 internal/keysigner/signer_sks_test.go create mode 100644 release_config_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02e2274d19..9691a817a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,11 @@ permissions: contents: read jobs: + # All platforms (incl. darwin keychain_signer) are CGO-free and cross-compiled + # on a single ubuntu runner in one goreleaser run (one checksums.txt). The + # darwin signer's runtime FFI is validated separately by the signer-test job. goreleaser: + needs: signer-test-macos runs-on: ubuntu-22.04 permissions: contents: write @@ -34,6 +38,21 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Validate the macOS keychain signer on real hardware. The release binaries are + # cross-compiled on ubuntu (CGO-free purego FFI), so this is the only step that + # needs a Mac — and it gates the release rather than producing it. + signer-test-macos: + runs-on: macos-latest + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: '1.23' + - name: Keychain signer round-trip (CGO-free purego FFI) + run: LARK_KEYCHAIN_IT=1 CGO_ENABLED=0 go test -tags keychain_signer -run Keychain -v ./internal/keysigner/ + publish-npm: needs: goreleaser runs-on: ubuntu-22.04 diff --git a/.goreleaser.yml b/.goreleaser.yml index 9fc7f62b05..7a5b27f3a2 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -5,25 +5,63 @@ before: - python3 scripts/fetch_meta.py builds: - - binary: lark-cli + # Linux & Windows: pure-Go TPM 2.0 signer is compiled in by default (no build + # tag), cross-compiled with CGO disabled — the binaries ship the platform key + # signer for private_key_jwt. windows/arm64 is the one exception: the sks + # Windows dependency stack (go-ole) has no arm64 support, so the signer file is + # arch-excluded there and that binary falls back to client_secret only. + - id: linux + binary: lark-cli + main: . env: - CGO_ENABLED=0 + flags: + - -trimpath ldflags: - -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }} goos: - - darwin - linux - - windows goarch: - amd64 - arm64 - riscv64 + - id: windows + binary: lark-cli + main: . + env: + - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }} + goos: + - windows + goarch: + - amd64 + - arm64 + # macOS: the keychain signer calls Security.framework via runtime FFI (purego), + # so it is CGO-free, compiled into every darwin build (no build tag), and + # cross-compiles from the same ubuntu runner as linux/windows. + - id: darwin + binary: lark-cli + main: . + env: + - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }} + goos: + - darwin + goarch: + - amd64 + - arm64 archives: - name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}" format_overrides: - goos: windows - format: zip + formats: [zip] files: - README.md - LICENSE diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 3b240b1765..ae2d952e95 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -40,6 +40,10 @@ type LoginOptions struct { var pollDeviceToken = larkauth.PollDeviceToken +var resolveLoginClientAuth = func(ctx context.Context, cfg *core.CliConfig) (larkauth.ClientAuth, error) { + return larkauth.ClientAuthFromConfig(cfg).ResolveSigner(ctx) +} + // NewCmdAuthLogin creates the auth login subcommand. func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command { opts := &LoginOptions{Factory: f} @@ -265,7 +269,11 @@ func authLoginRun(opts *LoginOptions) error { if err != nil { return err } - authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut) + clientAuth, err := resolveLoginClientAuth(opts.Ctx, config) + if err != nil { + return errs.NewAuthenticationError(errs.SubtypeUnknown, "device authorization failed: %v", err).WithCause(err) + } + authResp, err := larkauth.RequestDeviceAuthorization(opts.Ctx, httpClient, clientAuth, config.Brand, finalScope, f.IOStreams.ErrOut) if err != nil { return errs.NewAuthenticationError(errs.SubtypeUnknown, "device authorization failed: %v", err).WithCause(err) } @@ -325,7 +333,7 @@ func authLoginRun(opts *LoginOptions) error { // Step 3: Poll for token log(msg.WaitingAuth) - result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand, + result := pollDeviceToken(opts.Ctx, httpClient, clientAuth, config.Brand, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut) if !result.OK { @@ -398,6 +406,10 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo if err != nil { return err } + clientAuth, err := resolveLoginClientAuth(opts.Ctx, config) + if err != nil { + return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %v", err).WithCause(err) + } requestedScope, err := loadLoginRequestedScope(opts.DeviceCode) if err != nil { fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to load cached requested scopes: %v\n", err) @@ -415,7 +427,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint) } log(msg.WaitingAuth) - result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand, + result := pollDeviceToken(opts.Ctx, httpClient, clientAuth, config.Brand, opts.DeviceCode, 5, 600, f.IOStreams.ErrOut) if !result.OK { diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index f2aa3389a9..2bcfdb1469 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -716,6 +716,14 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) { setupLoginConfigDir(t) t.Setenv("HOME", t.TempDir()) + originalResolve := resolveLoginClientAuth + resolveCalls := 0 + resolveLoginClientAuth = func(_ context.Context, cfg *core.CliConfig) (larkauth.ClientAuth, error) { + resolveCalls++ + return larkauth.ClientAuthFromConfig(cfg), nil + } + t.Cleanup(func() { resolveLoginClientAuth = originalResolve }) + multi := &core.MultiAppConfig{ CurrentApp: "default", Apps: []core.AppConfig{ @@ -778,6 +786,9 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) { if err != nil { t.Fatalf("no-wait authLoginRun() error = %v", err) } + if resolveCalls != 1 { + t.Fatalf("no-wait client auth preparations = %d, want 1", resolveCalls) + } if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "im:message:send" { t.Fatalf("loadLoginRequestedScope() = (%q, %v), want requested scope", got, err) } @@ -793,6 +804,9 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) { if err != nil { t.Fatalf("device-code authLoginRun() error = %v", err) } + if resolveCalls != 2 { + t.Fatalf("split-flow client auth preparations = %d, want one per invocation", resolveCalls) + } got := stderr.String() for _, want := range []string{ "OK: 授权成功! 用户: tester (ou_user)", @@ -847,7 +861,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) { original := pollDeviceToken t.Cleanup(func() { pollDeviceToken = original }) - pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult { + pollDeviceToken = func(ctx context.Context, httpClient *http.Client, ca larkauth.ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult { return &larkauth.DeviceFlowResult{OK: true, Token: nil} } @@ -884,9 +898,17 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) { keyring.MockInit() setupLoginConfigDir(t) + originalResolve := resolveLoginClientAuth + resolveCalls := 0 + resolveLoginClientAuth = func(_ context.Context, cfg *core.CliConfig) (larkauth.ClientAuth, error) { + resolveCalls++ + return larkauth.ClientAuthFromConfig(cfg), nil + } + t.Cleanup(func() { resolveLoginClientAuth = originalResolve }) + original := pollDeviceToken t.Cleanup(func() { pollDeviceToken = original }) - pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult { + pollDeviceToken = func(ctx context.Context, httpClient *http.Client, ca larkauth.ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult { return &larkauth.DeviceFlowResult{OK: false, Message: "user denied"} } @@ -919,6 +941,9 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) { if err == nil { t.Fatal("expected error for aborted authorization") } + if resolveCalls != 1 { + t.Fatalf("blocking-flow client auth preparations = %d, want 1", resolveCalls) + } if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth { t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth) } diff --git a/cmd/config/bind.go b/cmd/config/bind.go index 67916b17e5..f040215c2c 100644 --- a/cmd/config/bind.go +++ b/cmd/config/bind.go @@ -4,12 +4,18 @@ package config import ( + "bytes" + "context" "encoding/json" "fmt" "os" + "path/filepath" "strings" + "sync" + "time" "github.com/charmbracelet/huh" + "github.com/gofrs/flock" "github.com/spf13/cobra" "github.com/larksuite/cli/errs" @@ -22,9 +28,14 @@ import ( "github.com/larksuite/cli/internal/vfs" ) +const bindCommitLockTimeout = 5 * time.Second + +var bindCommitMu sync.Mutex + // BindOptions holds all inputs for config bind. type BindOptions struct { Factory *cmdutil.Factory + Ctx context.Context Source string AppID string // Identity selects one of two presets — "bot-only" or "user-default" — @@ -94,6 +105,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`, # Interactive (terminal user) — TUI prompts for everything: lark-cli config bind`, RunE: func(cmd *cobra.Command, args []string) error { + opts.Ctx = cmd.Context() opts.langExplicit = cmd.Flags().Changed("lang") if runF != nil { return runF(opts) @@ -139,10 +151,11 @@ func configBindRun(opts *BindOptions) error { return nil } - appConfig, err := resolveAccount(opts, source) + result, err := resolveAccount(opts, source) if err != nil { return err } + appConfig := result.AppConfig opts.Brand = string(appConfig.Brand) if err := resolveIdentity(opts); err != nil { @@ -151,10 +164,20 @@ func configBindRun(opts *BindOptions) error { if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil { return err } - applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes)) + if err := validateBindResult(bindContext(opts), opts, result); err != nil { + return err + } + applyPreferences(appConfig, opts, priorLangForApp(existing.ConfigBytes, appConfig.AppId)) noticeUserDefaultRisk(opts) - return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath) + return commitBinding(opts, result, existing.ConfigBytes, source, targetConfigPath) +} + +func bindContext(opts *BindOptions) context.Context { + if opts != nil && opts.Ctx != nil { + return opts.Ctx + } + return context.Background() } // existingBinding is the outcome of checking whether a workspace was already @@ -239,9 +262,15 @@ func finalizeSource(opts *BindOptions) (string, error) { // notice on success so the caller still sees that a rebind happened. // See existingBinding for the returned fields. func reconcileExistingBinding(opts *BindOptions, source, configPath string) (existingBinding, error) { - oldConfigData, _ := vfs.ReadFile(configPath) - if oldConfigData == nil { - return existingBinding{}, nil + oldConfigData, err := vfs.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + return existingBinding{}, nil + } + return existingBinding{}, errs.NewConfigError(errs.SubtypeInvalidConfig, + "cannot read existing workspace config %s: %v", configPath, err). + WithHint("fix the file permissions or I/O error before binding"). + WithCause(err) } if opts.IsTUI { @@ -264,7 +293,7 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi // enumerate candidates, pick one via the shared decision layer, and build a // ready-to-persist AppConfig. Adding a new bind source only requires // implementing SourceBinder — none of the logic below needs to change. -func resolveAccount(opts *BindOptions, source string) (*core.AppConfig, error) { +func resolveAccount(opts *BindOptions, source string) (*BindResult, error) { binder, err := newBinder(source, opts) if err != nil { return nil, err @@ -278,7 +307,7 @@ func resolveAccount(opts *BindOptions, source string) (*core.AppConfig, error) { if err != nil { return nil, err } - return binder.Build(picked.AppID) + return binder.Build(bindContext(opts), *picked) } // resolveIdentity ensures opts.Identity is set before applyPreferences runs. @@ -389,10 +418,21 @@ func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.L // wrong profile's preference into a re-bind when the workspace holds multiple // named profiles and the active one disagrees with Apps[0]. func priorLang(previousConfigBytes []byte) i18n.Lang { + return priorLangForApp(previousConfigBytes, "") +} + +func priorLangForApp(previousConfigBytes []byte, appID string) i18n.Lang { var multi core.MultiAppConfig if json.Unmarshal(previousConfigBytes, &multi) != nil { return "" } + if appID != "" { + for i := range multi.Apps { + if multi.Apps[i].AppId == appID { + return multi.Apps[i].Lang + } + } + } if app := multi.CurrentAppConfig(""); app != nil { return app.Lang } @@ -400,12 +440,16 @@ func priorLang(previousConfigBytes []byte) i18n.Lang { } // commitBinding finalizes the bind: atomic write of the new workspace config, -// best-effort cleanup of stale keychain entries from the previous binding (if -// any), and a JSON success envelope. Cleanup runs only after the new config -// is durably written — if anything fails earlier, the old workspace stays -// usable. -func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigBytes []byte, source, configPath string) error { - multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}} +// deferred provider-manifest commit for keyless binds, and a JSON success +// envelope. The write and provider commit are serialized across CLI processes; +// if the provider commit fails, the workspace write is rolled back before any +// success output so an existing binding remains usable. +func commitBinding(opts *BindOptions, result *BindResult, previousConfigBytes []byte, source, configPath string) error { + appConfig := result.AppConfig + multi, err := mergeBoundApp(appConfig, previousConfigBytes, opts.langExplicit) + if err != nil { + return err + } if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil { return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err) @@ -414,9 +458,38 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB if err != nil { return errs.NewInternalError(errs.SubtypeStorage, "failed to marshal config: %v", err).WithCause(err) } - if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil { + releaseCommitLock, err := acquireBindCommitLock(opts) + if err != nil { + return err + } + commitLockHeld := true + defer func() { + if commitLockHeld { + releaseCommitLock() + } + }() + if err := ensureBindingSnapshotUnchanged(configPath, previousConfigBytes); err != nil { + return err + } + newConfigBytes := append(data, '\n') + if err := validate.AtomicWrite(configPath, newConfigBytes, 0600); err != nil { return errs.NewInternalError(errs.SubtypeStorage, "failed to write config %s: %v", configPath, err).WithCause(err) } + if result.commitProviderManifest != nil { + if err := result.commitProviderManifest(); err != nil { + rollbackErr := rollbackBindingConfig(configPath, previousConfigBytes, newConfigBytes) + if rollbackErr != nil { + return errs.NewInternalError(errs.SubtypeStorage, + "failed to persist keyless signer provider: %v; failed to restore workspace config: %v", err, rollbackErr). + WithCause(err) + } + return errs.NewInternalError(errs.SubtypeStorage, + "failed to persist keyless signer provider (workspace config restored): %v", err). + WithCause(err) + } + } + releaseCommitLock() + commitLockHeld = false replaced := previousConfigBytes != nil // uiMsg renders human-facing TUI text (stderr success banner). Follows @@ -425,10 +498,6 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB uiMsg := getBindMsg(opts.UILang) display := sourceDisplayName(source) - if replaced { - cleanupKeychainFromData(opts.Factory.Keychain, previousConfigBytes, appConfig) - } - fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(uiMsg.BindSuccessHeader, display)+"\n"+uiMsg.BindSuccessNotice) @@ -470,6 +539,133 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB return nil } +func acquireBindCommitLock(opts *BindOptions) (func(), error) { + bindCommitMu.Lock() + lockDir := filepath.Join(core.GetBaseConfigDir(), "locks") + if err := vfs.MkdirAll(lockDir, 0700); err != nil { + bindCommitMu.Unlock() + return nil, errs.NewInternalError(errs.SubtypeStorage, + "failed to create bind lock directory: %v", err).WithCause(err) + } + fileLock := flock.New(filepath.Join(lockDir, "config-bind.lock")) + ctx, cancel := context.WithTimeout(bindContext(opts), bindCommitLockTimeout) + locked, err := fileLock.TryLockContext(ctx, 50*time.Millisecond) + cancel() + if err != nil || !locked { + bindCommitMu.Unlock() + if err == nil { + err = context.DeadlineExceeded + } + return nil, errs.NewInternalError(errs.SubtypeStorage, + "failed to acquire config bind lock: %v", err).WithCause(err) + } + return func() { + _ = fileLock.Unlock() + bindCommitMu.Unlock() + }, nil +} + +func ensureBindingSnapshotUnchanged(configPath string, previousConfigBytes []byte) error { + current, err := vfs.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) && previousConfigBytes == nil { + return nil + } + return errs.NewInternalError(errs.SubtypeStorage, + "failed to recheck workspace config %s before binding: %v", configPath, err).WithCause(err) + } + if previousConfigBytes != nil && bytes.Equal(current, previousConfigBytes) { + return nil + } + return errs.NewConfigError(errs.SubtypeInvalidConfig, + "workspace config %s changed while the bind was being validated", configPath). + WithHint("retry config bind using the latest workspace state") +} + +func rollbackBindingConfig(configPath string, previousConfigBytes, writtenConfigBytes []byte) error { + current, err := vfs.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) && previousConfigBytes == nil { + return nil + } + //nolint:forbidigo // intermediate rollback diagnostic; commitBinding wraps it into a typed storage error + return fmt.Errorf("recheck workspace config before rollback: %w", err) + } + if !bytes.Equal(current, writtenConfigBytes) { + //nolint:forbidigo // intermediate rollback diagnostic; commitBinding wraps it into a typed storage error + return fmt.Errorf("workspace config changed after the bind write; refusing to overwrite it during rollback") + } + if previousConfigBytes != nil { + return validate.AtomicWrite(configPath, previousConfigBytes, 0600) + } + if err := vfs.Remove(configPath); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// mergeBoundApp upserts by unique appId and activates the target while +// preserving every non-target profile and root policy. +func mergeBoundApp(incoming *core.AppConfig, previousBytes []byte, langExplicit bool) (*core.MultiAppConfig, error) { + if incoming == nil || strings.TrimSpace(incoming.AppId) == "" { + return nil, errs.NewInternalError(errs.SubtypeSDKError, "config bind produced an empty app") + } + if previousBytes == nil { + return &core.MultiAppConfig{Apps: []core.AppConfig{*incoming}, CurrentApp: incoming.ProfileName()}, nil + } + + var multi core.MultiAppConfig + if err := json.Unmarshal(previousBytes, &multi); err != nil { + return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, + "cannot update malformed workspace config: %v", err).WithCause(err) + } + match := -1 + for i := range multi.Apps { + if multi.Apps[i].AppId != incoming.AppId { + continue + } + if match >= 0 { + return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, + "appId %s appears in multiple CLI profiles", incoming.AppId). + WithHint("remove the duplicate profile before binding") + } + match = i + } + + oldActive := "" + if active := multi.CurrentAppConfig(""); active != nil { + oldActive = active.ProfileName() + } + if match >= 0 { + old := multi.Apps[match] + incoming.Name = old.Name + incoming.Users = old.Users + if !langExplicit { + incoming.Lang = old.Lang + } + multi.Apps[match] = *incoming + } else { + for i := range multi.Apps { + if multi.Apps[i].Name != "" && multi.Apps[i].Name == incoming.AppId { + return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, + "new appId %s conflicts with existing profile name", incoming.AppId). + WithHint("rename the existing profile before binding") + } + } + incoming.Name = "" + incoming.Users = []core.AppUser{} + multi.Apps = append(multi.Apps, *incoming) + match = len(multi.Apps) - 1 + } + + targetName := multi.Apps[match].ProfileName() + if oldActive != targetName { + multi.PreviousApp = oldActive + multi.CurrentApp = targetName + } + return &multi, nil +} + // cleanupKeychainFromData removes keychain entries referenced by a previous // config snapshot, skipping any entry whose keychain ID is still in use by // the new app config. This prevents rebinding the same appId from deleting diff --git a/cmd/config/bind_test.go b/cmd/config/bind_test.go index 04ea8442a2..42a4c0920c 100644 --- a/cmd/config/bind_test.go +++ b/cmd/config/bind_test.go @@ -84,6 +84,21 @@ func saveWorkspace(t *testing.T) { t.Cleanup(func() { core.SetCurrentWorkspace(orig) }) } +func TestReconcileExistingBinding_ReadFailureIsNotTreatedAsMissing(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + if err := os.Mkdir(configPath, 0700); err != nil { + t.Fatal(err) + } + f, _, _, _ := cmdutil.TestFactory(t, nil) + _, err := reconcileExistingBinding(&BindOptions{Factory: f}, "openclaw", configPath) + if err == nil || !strings.Contains(err.Error(), "cannot read existing workspace config") { + t.Fatalf("reconcileExistingBinding error = %v", err) + } + if info, statErr := os.Stat(configPath); statErr != nil || !info.IsDir() { + t.Fatalf("unreadable existing config was changed: info=%v error=%v", info, statErr) + } +} + // ── Command flag parsing tests (aligned with config_test.go pattern) ── func TestConfigBindCmd_FlagParsing(t *testing.T) { @@ -1497,7 +1512,11 @@ func assertPresetApplied(t *testing.T, configPath string, wantStrict core.Strict if len(multi.Apps) == 0 { t.Fatalf("no apps in %s", configPath) } - app := multi.Apps[0] + appPtr := multi.CurrentAppConfig("") + if appPtr == nil { + t.Fatalf("no current app in %s", configPath) + } + app := *appPtr if app.StrictMode == nil || *app.StrictMode != wantStrict { t.Errorf("StrictMode = %v, want %q", app.StrictMode, wantStrict) } diff --git a/cmd/config/binder.go b/cmd/config/binder.go index a24f927be1..8b884b3976 100644 --- a/cmd/config/binder.go +++ b/cmd/config/binder.go @@ -4,6 +4,7 @@ package config import ( + "context" "fmt" "os" "path/filepath" @@ -23,6 +24,19 @@ type Candidate struct { Label string } +// BindResult carries the selected app. External signer configuration is the +// logical provider on AppConfig.KeyRef; bind never persists executable paths. +type BindResult struct { + AppConfig *core.AppConfig + + // commitProviderManifest is populated only after a keyless bind probe has + // authenticated successfully. commitBinding runs it after the workspace + // config write and rolls that write back if the global provider index cannot + // be committed, so a failed bind never changes the signer used by existing + // applications. + commitProviderManifest func() error +} + // SourceBinder abstracts a bind source (openclaw / hermes / future sources). // Implementations only list candidates and build an AppConfig for a chosen // candidate — they stay out of mode (TUI vs flag) and orchestration concerns. @@ -34,9 +48,9 @@ type SourceBinder interface { // ListCandidates enumerates bindable accounts from the source config. // An empty slice is valid (selectCandidate will turn it into a typed error). ListCandidates() ([]Candidate, error) - // Build resolves secrets, persists to keychain, and returns a ready AppConfig - // for the chosen candidate AppID. Must be called after ListCandidates succeeds. - Build(appID string) (*core.AppConfig, error) + // Build resolves credentials and returns the app plus any signer command + // needed by the workspace. Must be called after ListCandidates succeeds. + Build(ctx context.Context, candidate Candidate) (*BindResult, error) } // newBinder constructs the SourceBinder for the given source name. @@ -93,11 +107,21 @@ func selectCandidate( } if appIDFlag != "" { + var matches []Candidate for i := range candidates { if candidates[i].AppID == appIDFlag { - return &candidates[i], nil + matches = append(matches, candidates[i]) } } + if len(matches) == 1 { + return &matches[0], nil + } + if len(matches) > 1 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--app-id %q matches multiple accounts in %s", appIDFlag, cfgBase). + WithHint("run 'lark-cli config bind' interactively to choose an account, or configure unique app IDs:\n %s", formatCandidates(matches)). + WithParam("--app-id") + } return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id %q not found in %s", appIDFlag, cfgBase). WithHint("available app IDs:\n %s", formatCandidates(candidates)). WithParam("--app-id") @@ -168,20 +192,48 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) { return result, nil } -func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) { +func (b *openclawBinder) Build(_ context.Context, candidate Candidate) (*BindResult, error) { if b.cfg == nil { return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates") } var selected *binding.CandidateApp for i := range b.rawApps { - if b.rawApps[i].AppID == appID { + if b.rawApps[i].AppID == candidate.AppID && b.rawApps[i].Label == candidate.Label { selected = &b.rawApps[i] break } } if selected == nil { - return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID) + return nil, errs.NewInternalError(errs.SubtypeSDKError, + "internal: account %q (appID %q) not in candidates", candidate.Label, candidate.AppID) + } + if selected.AuthMethod != "" && selected.AuthMethod != "app_secret" && selected.AuthMethod != binding.AuthMethodPrivateKeyJWT { + return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "unknown authMethod %q for app %s in %s", selected.AuthMethod, selected.AppID, b.path). + WithHint("supported values are app_secret and private_key_jwt") + } + + // openclaw-lark deliberately gives appSecret precedence when both shapes + // are present. Reproduce that behavior so bind never changes the effective + // credential type merely because authMethod was left stale. + if selected.AppSecret.IsZero() && selected.AuthMethod == binding.AuthMethodPrivateKeyJWT { + if strings.TrimSpace(selected.KeyRef) == "" { + return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, + "private_key_jwt app %s in %s is missing keyRef", selected.AppID, b.path). + WithHint("re-run OpenClaw onboarding so the keyless account records its signer keyRef") + } + return &BindResult{ + AppConfig: &core.AppConfig{ + AppId: selected.AppID, + Brand: core.ParseBrand(selected.Brand), + AuthMethod: core.AuthMethodPrivateKeyJWT, + KeyRef: &core.SecretRef{ + Source: core.SecretSourceTEE, + Provider: core.KeylessProviderLarkSuite, + ID: strings.TrimSpace(selected.KeyRef), + }, + }, + }, nil } if selected.AppSecret.IsZero() { @@ -202,11 +254,11 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) { WithCause(err) } - return &core.AppConfig{ + return &BindResult{AppConfig: &core.AppConfig{ AppId: selected.AppID, AppSecret: stored, Brand: core.ParseBrand(selected.Brand), - }, nil + }}, nil } // ────────────────────────────────────────────────────────────── @@ -238,7 +290,8 @@ func (b *hermesBinder) ListCandidates() ([]Candidate, error) { return []Candidate{{AppID: appID, Label: "default"}}, nil } -func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) { +func (b *hermesBinder) Build(_ context.Context, candidate Candidate) (*BindResult, error) { + appID := candidate.AppID if b.envMap == nil { return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates") } @@ -258,11 +311,11 @@ func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) { WithCause(err) } - return &core.AppConfig{ + return &BindResult{AppConfig: &core.AppConfig{ AppId: appID, AppSecret: stored, Brand: core.ParseBrand(b.envMap["FEISHU_DOMAIN"]), - }, nil + }}, nil } // ────────────────────────────────────────────────────────────── @@ -295,7 +348,8 @@ func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) { return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil } -func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) { +func (b *larkChannelBinder) Build(_ context.Context, candidate Candidate) (*BindResult, error) { + appID := candidate.AppID if b.cfg == nil { return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates") } @@ -323,11 +377,11 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) { WithCause(err) } - return &core.AppConfig{ + return &BindResult{AppConfig: &core.AppConfig{ AppId: appID, AppSecret: stored, Brand: core.ParseBrand(b.cfg.Accounts.App.Tenant), - }, nil + }}, nil } // ────────────────────────────────────────────────────────────── diff --git a/cmd/config/binder_test.go b/cmd/config/binder_test.go index ee7c7e6de2..5ce2ec0e9c 100644 --- a/cmd/config/binder_test.go +++ b/cmd/config/binder_test.go @@ -4,10 +4,12 @@ package config import ( + "context" "path/filepath" "reflect" "testing" + "github.com/larksuite/cli/internal/binding" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" ) @@ -20,10 +22,10 @@ type fakeBinder struct { path string } -func (b *fakeBinder) Name() string { return b.name } -func (b *fakeBinder) ConfigPath() string { return b.path } -func (b *fakeBinder) ListCandidates() ([]Candidate, error) { return nil, nil } -func (b *fakeBinder) Build(appID string) (*core.AppConfig, error) { return nil, nil } +func (b *fakeBinder) Name() string { return b.name } +func (b *fakeBinder) ConfigPath() string { return b.path } +func (b *fakeBinder) ListCandidates() ([]Candidate, error) { return nil, nil } +func (b *fakeBinder) Build(context.Context, Candidate) (*BindResult, error) { return nil, nil } // tuiUnreachable is a tuiPrompt that fails the test if called. It's the // guardrail that proves the non-TUI decision paths really do stay out of the @@ -107,6 +109,20 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) { }) } +func TestSelectCandidate_AppIDFlag_RejectsDuplicateInheritedAppID(t *testing.T) { + b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"} + candidates := []Candidate{ + {AppID: "cli_shared", Label: "work"}, + {AppID: "cli_shared", Label: "personal"}, + } + _, err := selectCandidate(b, candidates, "cli_shared", false, tuiUnreachable(t)) + assertExitError(t, err, output.ExitValidation, wantErrDetail{ + Type: "validation", + Message: `--app-id "cli_shared" matches multiple accounts in openclaw.json`, + Hint: "run 'lark-cli config bind' interactively to choose an account, or configure unique app IDs:\n cli_shared (work)\n cli_shared (personal)", + }) +} + func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) { // Flag-mode with multiple candidates and no --app-id must produce a // validation error and the candidate list, never an interactive prompt. @@ -175,6 +191,27 @@ func TestSelectCandidate_AppIDFlag_WinsOverTUI(t *testing.T) { assertCandidate(t, got, Candidate{AppID: "cli_b"}) } +func TestOpenClawBuildUsesSelectedLabelWhenAppIDIsShared(t *testing.T) { + b := &openclawBinder{ + cfg: &binding.OpenClawRoot{}, + rawApps: []binding.CandidateApp{ + {Label: "work", AppID: "cli_shared", AuthMethod: binding.AuthMethodPrivateKeyJWT, KeyRef: "work-key"}, + {Label: "personal", AppID: "cli_shared", AuthMethod: binding.AuthMethodPrivateKeyJWT, KeyRef: "personal-key"}, + }, + } + + result, err := b.Build(context.Background(), Candidate{AppID: "cli_shared", Label: "personal"}) + if err != nil { + t.Fatal(err) + } + if result.AppConfig.KeyRef == nil || result.AppConfig.KeyRef.ID != "personal-key" { + t.Fatalf("keyRef = %#v, want personal-key", result.AppConfig.KeyRef) + } + if result.AppConfig.KeyRef.Provider != core.KeylessProviderLarkSuite { + t.Fatalf("provider = %q, want %q", result.AppConfig.KeyRef.Provider, core.KeylessProviderLarkSuite) + } +} + func TestResolveLarkChannelConfigPath_Default(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go index 9f0f3da62c..a4e0ffce63 100644 --- a/cmd/config/config_test.go +++ b/cmd/config/config_test.go @@ -65,6 +65,39 @@ func TestConfigInitCmd_FlagParsing(t *testing.T) { } } +func TestConfigInitCmd_PrivateKeyJWTFlag(t *testing.T) { + clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts + f, _, _, _ := cmdutil.TestFactory(t, nil) + + var gotOpts *ConfigInitOptions + cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--new", "--private-key-jwt"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !gotOpts.PrivateKeyJWT { + t.Error("PrivateKeyJWT = false, want true") + } +} + +func TestConfigInitCmd_AuthMethodFlagRemoved(t *testing.T) { + clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts + f, _, _, _ := cmdutil.TestFactory(t, nil) + + cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error { return nil }) + cmd.SetArgs([]string{"--new", "--auth-method", core.AuthMethodPrivateKeyJWT}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected unknown flag error") + } + if !strings.Contains(err.Error(), "unknown flag: --auth-method") { + t.Fatalf("error = %v, want unknown --auth-method flag", err) + } +} + func TestConfigShowCmd_FlagParsing(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, @@ -193,7 +226,7 @@ func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) { t.Fatalf("seed config: %v", err) } - if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil { + if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, "", "", nil); err != nil { t.Fatalf("saveInitConfig (no --lang): %v", err) } @@ -206,6 +239,68 @@ func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) { } } +func TestKeyRefFromResult_PrivateKeyJWT(t *testing.T) { + ref := keyRefFromResult(&configInitResult{ + AuthMethod: core.AuthMethodPrivateKeyJWT, + KeyLabel: "lark-cli-default", + }) + if ref == nil { + t.Fatal("keyRefFromResult returned nil") + } + if ref.Source != "tee" || ref.ID != "lark-cli-default" { + t.Fatalf("key ref = %#v, want tee/lark-cli-default", ref) + } + + if ref := keyRefFromResult(&configInitResult{AuthMethod: core.AuthMethodPrivateKeyJWT}); ref != nil { + t.Fatalf("missing key label should not persist key ref, got %#v", ref) + } + if ref := keyRefFromResult(&configInitResult{AuthMethod: core.AuthMethodClientSecret, KeyLabel: "ignored"}); ref != nil { + t.Fatalf("client_secret should not persist key ref, got %#v", ref) + } + if ref := keyRefFromResult(nil); ref != nil { + t.Fatalf("nil result should not persist key ref, got %#v", ref) + } +} + +func TestPersistInitResult_PrivateKeyJWT(t *testing.T) { + for _, tc := range []struct { + name string + profile string + brand core.LarkBrand + }{ + {name: "single app", brand: core.BrandFeishu}, + {name: "named profile", profile: "prod", brand: core.BrandLark}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), Lang: "en_us"} + result := &configInitResult{ + Brand: tc.brand, AppID: "cli_pkjwt", + AuthMethod: core.AuthMethodPrivateKeyJWT, KeyLabel: "lark-cli-default", + } + if err := persistInitResult(opts, f, tc.profile, result); err != nil { + t.Fatal(err) + } + + got, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatal(err) + } + app := got.CurrentAppConfig(tc.profile) + if app == nil || app.AppId != "cli_pkjwt" || app.AuthMethod != core.AuthMethodPrivateKeyJWT { + t.Fatalf("saved app = %#v", app) + } + if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID != "lark-cli-default" { + t.Fatalf("KeyRef = %#v, want tee/lark-cli-default", app.KeyRef) + } + if !app.AppSecret.IsZero() { + t.Fatalf("private_key_jwt config must stay secretless, AppSecret value %#v", app.AppSecret) + } + }) + } +} + // TestConfigInitCmd_InvalidLang verifies a non-empty --lang on config init is // strictly validated the same way bind validates: wrong-case / typo / removed // codes / hyphen form all exit with ExitValidation. (Empty is a no-op.) @@ -388,7 +483,7 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T }, } - err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en") + err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en", "", nil) if err == nil { t.Fatal("expected conflict error") } @@ -427,6 +522,46 @@ func TestWrapSaveConfigError_PassesTypedValidationThrough(t *testing.T) { } } +func TestSaveAsProfile_UpdatePersistsPrivateKeyJWT(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + existing := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + Name: "prod", + AppId: "cli_prod", + AppSecret: core.PlainSecret("old-secret"), + Brand: core.BrandFeishu, + Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "User"}}, + }}, + } + keyRef := &core.SecretRef{Source: "tee", ID: "lark-cli-default"} + + if err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "prod", "cli_prod", core.SecretInput{}, core.BrandLark, "en_us", core.AuthMethodPrivateKeyJWT, keyRef); err != nil { + t.Fatalf("saveAsProfile update private_key_jwt: %v", err) + } + + got, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig: %v", err) + } + app := got.FindApp("prod") + if app == nil { + t.Fatalf("profile prod not saved: %#v", got.Apps) + } + if app.AuthMethod != core.AuthMethodPrivateKeyJWT { + t.Fatalf("AuthMethod = %q, want private_key_jwt", app.AuthMethod) + } + if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID != "lark-cli-default" { + t.Fatalf("KeyRef = %#v, want tee/lark-cli-default", app.KeyRef) + } + if app.AppSecret.Ref != nil || app.AppSecret.Plain != "" { + t.Fatalf("private_key_jwt update must stay secretless, AppSecret value %#v", app.AppSecret) + } + if len(app.Users) != 1 || app.Users[0].UserOpenId != "ou_1" { + t.Fatalf("same-app update should preserve users, Users=%#v", app.Users) + } +} + func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) { multi := &core.MultiAppConfig{ CurrentApp: "prod", diff --git a/cmd/config/init.go b/cmd/config/init.go index 544c4c60be..ac92cf5c09 100644 --- a/cmd/config/init.go +++ b/cmd/config/init.go @@ -19,6 +19,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/keychain" + "github.com/larksuite/cli/internal/keysigner" "github.com/larksuite/cli/internal/output" ) @@ -31,6 +32,7 @@ type ConfigInitOptions struct { AppSecretStdin bool // read app-secret from stdin (avoids process list exposure) Brand string New bool + PrivateKeyJWT bool // --private-key-jwt: request private_key_jwt instead of the default client_secret Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang langExplicit bool // true when --lang was explicitly passed @@ -39,6 +41,8 @@ type ConfigInitOptions struct { ProfileName string // when set, create/update a named profile instead of replacing Apps[0] + Restore bool // Restore re-registers the app already in config to recover a lost credential + // ForceInit overrides the agent-workspace guard. Without it, running // init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller // at config bind — which is what AI agents almost always want. Manual @@ -81,17 +85,26 @@ if the user explicitly wants a separate app inside the Agent workspace.`, } cmd.Flags().BoolVar(&opts.New, "new", false, "create a new app directly (skip mode selection)") + cmd.Flags().BoolVar(&opts.PrivateKeyJWT, "private-key-jwt", false, "create a new app with private_key_jwt (signed by a platform key, no app secret)") cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)") cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure") cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)") cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)") cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)") + cmd.Flags().BoolVar(&opts.Restore, "restore", false, "re-register the app already in config to recover a lost credential (keychain key / app secret); reuses the stored app ID and auth method") cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app") cmdutil.SetRisk(cmd, "write") return cmd } +func requestedInitAuthMethod(opts *ConfigInitOptions) string { + if opts.PrivateKeyJWT { + return core.AuthMethodPrivateKeyJWT + } + return core.AuthMethodClientSecret +} + // printLangPreferenceConfirmation echoes the set preference to stderr, only // when --lang explicitly set a non-empty value. func printLangPreferenceConfirmation(opts *ConfigInitOptions) { @@ -132,7 +145,7 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error { // hasAnyNonInteractiveFlag returns true if any non-interactive flag is set. func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool { - return o.New || o.AppID != "" || o.AppSecretStdin + return o.New || o.Restore || o.AppID != "" || o.AppSecretStdin } // cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID. @@ -151,22 +164,61 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp } } +// removeStaleSecretForPKJWT clears a secret left in the keychain when the SAME +// appId is migrated from client_secret to private_key_jwt. cleanupOldConfig +// explicitly skips a matching appId, and saveAsProfile only cleans up on an +// appId change, so a same-appId migration would orphan the old secret. This +// fills that gap. RemoveSecretStore only deletes Source=="keychain" entries, so +// the new pkjwt tee key handle is never touched. +func removeStaleSecretForPKJWT(existing *core.MultiAppConfig, profileName, appID string, kc keychain.KeychainAccess) { + if existing == nil { + return + } + var prior *core.AppConfig + if profileName != "" { + if idx := findProfileIndexByName(existing, profileName); idx >= 0 { + prior = &existing.Apps[idx] + } + } else { + prior = existing.CurrentAppConfig("") + } + if prior != nil && prior.AppId == appID && !prior.AppSecret.IsZero() { + core.RemoveSecretStore(prior.AppSecret, kc) + } +} + +// keyRefFromResult builds the TEE key reference to persist for a private_key_jwt +// registration result, or nil for client_secret. +func keyRefFromResult(r *configInitResult) *core.SecretRef { + if r != nil && r.AuthMethod == core.AuthMethodPrivateKeyJWT && r.KeyLabel != "" { + return &core.SecretRef{Source: "tee", ID: r.KeyLabel} + } + return nil +} + // saveAsOnlyApp overwrites config.json with a single-app config. -func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error { +func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error { config := &core.MultiAppConfig{ Apps: []core.AppConfig{{ AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{}, + AuthMethod: authMethod, KeyRef: keyRef, }}, } + return saveMultiAppConfigForInit(config) +} + +func saveMultiAppConfigForInit(config *core.MultiAppConfig) error { return core.SaveMultiAppConfig(config) } // saveInitConfig saves a new/updated app config, respecting --profile mode. // With profileName: appends or updates the named profile (preserves other profiles). // Without profileName: cleans up old config and saves as the only app. -func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error { +// authMethod/keyRef carry the credential type: ("", nil) for client_secret, +// (private_key_jwt, &{tee,label}) for the secretless TEE flow. +func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error { if profileName != "" { - return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang) + return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang, authMethod, keyRef) } cleanupOldConfig(existing, f, appId) var prior i18n.Lang @@ -175,7 +227,7 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti prior = app.Lang } } - return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior))) + return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)), authMethod, keyRef) } // wrapSaveConfigError passes an already-typed error (e.g. the --name conflict @@ -195,7 +247,7 @@ func wrapSaveConfigError(err error) error { // saveAsProfile appends or updates a named profile in the config. // If a profile with the same name exists, it updates it; otherwise appends. // When updating, cleans up old keychain secrets if AppId changed. -func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error { +func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error { multi := existing if multi == nil { multi = &core.MultiAppConfig{} @@ -214,6 +266,8 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr multi.Apps[idx].AppSecret = secret multi.Apps[idx].Brand = brand multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang) + multi.Apps[idx].AuthMethod = authMethod + multi.Apps[idx].KeyRef = keyRef } else { if findAppIndexByAppID(multi, profileName) >= 0 { return errs.NewValidationError(errs.SubtypeInvalidArgument, @@ -222,15 +276,17 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr } // Append new profile multi.Apps = append(multi.Apps, core.AppConfig{ - Name: profileName, - AppId: appId, - AppSecret: secret, - Brand: brand, - Lang: i18n.Lang(lang), - Users: []core.AppUser{}, + Name: profileName, + AppId: appId, + AppSecret: secret, + Brand: brand, + Lang: i18n.Lang(lang), + Users: []core.AppUser{}, + AuthMethod: authMethod, + KeyRef: keyRef, }) } - return core.SaveMultiAppConfig(multi) + return saveMultiAppConfigForInit(multi) } func findProfileIndexByName(multi *core.MultiAppConfig, profileName string) int { @@ -302,12 +358,141 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa app.AppId = appID app.Brand = brand app.Lang = preferredLang(i18n.Lang(lang), app.Lang) - return core.SaveMultiAppConfig(existing) + return saveMultiAppConfigForInit(existing) +} + +func persistInitResult(opts *ConfigInitOptions, f *cmdutil.Factory, profileName string, result *configInitResult) error { + existing, _ := core.LoadMultiAppConfig() + + switch { + case result.AuthMethod == core.AuthMethodPrivateKeyJWT: + if err := saveInitConfig(profileName, existing, f, result.AppID, core.SecretInput{}, result.Brand, opts.Lang, result.AuthMethod, keyRefFromResult(result)); err != nil { + return wrapSaveConfigError(err) + } + removeStaleSecretForPKJWT(existing, profileName, result.AppID, f.Keychain) + return nil + case result.AppSecret != "": + secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain) + if err != nil { + return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) + } + if err := saveInitConfig(profileName, existing, f, result.AppID, secret, result.Brand, opts.Lang, "", nil); err != nil { + return wrapSaveConfigError(err) + } + return nil + case result.Mode == "existing" && result.AppID != "": + return wrapUpdateExistingProfileErr(updateExistingProfileWithoutSecret(existing, profileName, result.AppID, result.Brand, opts.Lang)) + default: + return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").WithParam("--app-id") + } +} + +func probeInitResult(opts *ConfigInitOptions, f *cmdutil.Factory, result *configInitResult) error { + if result.AuthMethod == core.AuthMethodPrivateKeyJWT { + return runProbePKJWT(opts.Ctx, f, result.Brand, result.AppID, keysigner.Active(), result.KeyLabel) + } + if result.AppSecret != "" { + return runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand) + } + return nil +} + +// persistAndProbeResult saves a registration/restore result into profileName and +// runs the post-registration probe. profileName == "" replaces the single app +// (legacy); a named profile is updated in place. Shared by --new and --restore. +func persistAndProbeResult(opts *ConfigInitOptions, f *cmdutil.Factory, profileName string, result *configInitResult) error { + if err := persistInitResult(opts, f, profileName, result); err != nil { + return err + } + printLangPreferenceConfirmation(opts) + if result.AuthMethod == core.AuthMethodPrivateKeyJWT { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "authMethod": result.AuthMethod, "brand": result.Brand}) + } else { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand}) + } + return probeInitResult(opts, f, result) +} + +// runRestoreFlow re-registers the app already in config to recover a lost +// credential (deleted keychain key / lost app secret). It reads the existing +// app id + auth method + brand from config (no secret needed — that's the lost +// part) and re-runs the device-flow registration with the app id sent on begin, +// so the server re-registers that app instead of creating a new one. The +// re-issued credential is written back to the same profile. +func runRestoreFlow(opts *ConfigInitOptions, existing *core.MultiAppConfig, f *cmdutil.Factory, msg *initMsg) error { + if existing == nil { + return errs.NewConfigError(errs.SubtypeNotConfigured, "nothing to restore: no config found"). + WithHint("run: lark-cli config init") + } + app := existing.CurrentAppConfig(opts.ProfileName) + if app == nil || app.AppId == "" { + return errs.NewConfigError(errs.SubtypeNotConfigured, "nothing to restore: no app id in config%s", profileSuffix(opts.ProfileName)). + WithHint("run: lark-cli config init") + } + if app.KeyRef != nil && strings.TrimSpace(app.KeyRef.Provider) != "" { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, + "config init --restore does not manage external signer provider %q", app.KeyRef.Provider). + WithHint("repair the OpenClaw provider with onboarding doctor --fix, then run config bind again") + } + + restoreAppID := app.AppId + // Reuse the stored auth method authoritatively — never prompt. Empty on disk + // means client_secret (omitempty back-compat); pass it explicitly so restore + // preserves the existing credential type. + authMethod := app.AuthMethod + if authMethod == "" { + authMethod = core.AuthMethodClientSecret + } + result, err := runCreateAppFlow(opts.Ctx, f, app.Brand, authMethod, msg, restoreAppID) + if err != nil { + return err + } + if result == nil { + return errs.NewInternalError(errs.SubtypeSDKError, "app restore returned no result") + } + + // Safety: if the server did not honor app_id (e.g. not yet supported), it may + // have created a NEW app instead of restoring. Warn so the user is not silently + // switched to a different app id. + if result.AppID != restoreAppID { + fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] restore: server returned app %s, expected %s — it may have created a new app instead of restoring\n", result.AppID, restoreAppID) + } + + // Write back to the profile we restored: an explicit --name, else the resolved + // app's own name. Empty name => legacy single-app replace. + saveProfile := opts.ProfileName + if saveProfile == "" { + saveProfile = app.Name + } + return persistAndProbeResult(opts, f, saveProfile, result) +} + +// profileSuffix renders " (profile %q)" for error messages, or "" when unnamed. +func profileSuffix(profileName string) string { + if profileName == "" { + return "" + } + return fmt.Sprintf(" (profile %q)", profileName) } func configInitRun(opts *ConfigInitOptions) error { f := opts.Factory - + if opts.PrivateKeyJWT { + switch { + case opts.Restore: + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--private-key-jwt cannot be combined with --restore; restore preserves the stored auth method"). + WithParam("--private-key-jwt") + case opts.AppID != "": + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--private-key-jwt cannot be combined with --app-id; use --new to register a private_key_jwt app"). + WithParam("--private-key-jwt") + case opts.AppSecretStdin: + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--private-key-jwt cannot be combined with --app-secret-stdin; private_key_jwt does not use an app secret"). + WithParam("--private-key-jwt") + } + } // Read secret from stdin if --app-secret-stdin is set if opts.AppSecretStdin { scanner := bufio.NewScanner(f.IOStreams.In) @@ -335,6 +520,26 @@ func configInitRun(opts *ConfigInitOptions) error { } } + // --restore recovers an existing app; it is incompatible with creating a new + // app (--new) or importing one non-interactively (--app-id / stdin secret). + if opts.Restore { + if opts.New { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--restore cannot be combined with --new").WithParam("--restore") + } + if opts.AppID != "" || opts.AppSecretStdin { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--restore cannot be combined with --app-id / --app-secret-stdin").WithParam("--restore") + } + } + + // A user who explicitly asks for private_key_jwt needs immediate feedback + // before any interactive prompt. Otherwise unsupported machines enter the + // TUI and fail only after the user chooses a create flow. + if opts.PrivateKeyJWT && !opts.New && !opts.Restore { + if _, err := resolveRegisterAuthMethod(opts.Ctx, f, core.AuthMethodPrivateKeyJWT); err != nil { + return err + } + } + // Mode 1: Non-interactive if opts.AppID != "" && opts.appSecret != "" { brand := parseBrand(opts.Brand) @@ -342,7 +547,7 @@ func configInitRun(opts *ConfigInitOptions) error { if err != nil { return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } - if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil { + if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang, "", nil); err != nil { return wrapSaveConfigError(err) } output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) @@ -368,34 +573,26 @@ func configInitRun(opts *ConfigInitOptions) error { msg := getInitMsg(opts.UILang) + // Mode: Restore (--restore) — re-register the app already in config. + if opts.Restore { + return runRestoreFlow(opts, existing, f, msg) + } + // Mode 3: Create new app directly (--new) if opts.New { - result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg) + result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), requestedInitAuthMethod(opts), msg, "") if err != nil { return err } if result == nil { return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result") } - existing, _ := core.LoadMultiAppConfig() - secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain) - if err != nil { - return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) - } - if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil { - return wrapSaveConfigError(err) - } - printLangPreferenceConfirmation(opts) - output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand}) - if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil { - return err - } - return nil + return persistAndProbeResult(opts, f, opts.ProfileName, result) } // Mode 4: Interactive TUI (terminal) if !opts.hasAnyNonInteractiveFlag() && f.IOStreams.IsTerminal { - result, err := runInteractiveConfigInit(opts.Ctx, f, msg) + result, err := runInteractiveConfigInit(opts.Ctx, f, requestedInitAuthMethod(opts), msg) if err != nil { return err } @@ -404,35 +601,21 @@ func configInitRun(opts *ConfigInitOptions) error { WithParam("--app-id") } - existing, _ := core.LoadMultiAppConfig() - - if result.AppSecret != "" { - // New secret provided (either from "create" or "existing" with input) - secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain) - if err != nil { - return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) - } - if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil { - return wrapSaveConfigError(err) - } - } else if result.Mode == "existing" && result.AppID != "" { - // Existing app with unchanged secret — update app ID and brand only - if err := wrapUpdateExistingProfileErr(updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang)); err != nil { + if err := persistInitResult(opts, f, opts.ProfileName, result); err != nil { + return err + } + if result.AuthMethod == core.AuthMethodPrivateKeyJWT { + if err := probeInitResult(opts, f, result); err != nil { return err } - } else { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty"). - WithParam("--app-id") } if result.Mode == "existing" { output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID)) } printLangPreferenceConfirmation(opts) - if result.AppSecret != "" { - if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil { - return err - } + if result.AuthMethod != core.AuthMethodPrivateKeyJWT { + return probeInitResult(opts, f, result) } return nil } @@ -517,7 +700,7 @@ func configInitRun(opts *ConfigInitOptions) error { if err != nil { return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } - if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil { + if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang, "", nil); err != nil { return wrapSaveConfigError(err) } output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) diff --git a/cmd/config/init_auth_method_test.go b/cmd/config/init_auth_method_test.go new file mode 100644 index 0000000000..2ca1a6116d --- /dev/null +++ b/cmd/config/init_auth_method_test.go @@ -0,0 +1,306 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "context" + "crypto" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/keysigner" +) + +type authMethodTestSigner struct { + info keysigner.HardwareInfo + probeErr error +} + +func (authMethodTestSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) { + return nil, nil +} + +func (authMethodTestSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) { + return nil, nil +} + +func (authMethodTestSigner) Sign(context.Context, keysigner.KeyRef, []byte) ([]byte, string, error) { + return nil, "", nil +} + +func (s authMethodTestSigner) ProbeHardware(context.Context) (keysigner.HardwareInfo, error) { + return s.info, s.probeErr +} + +// TestResolveRegisterAuthMethod covers the non-interactive gating paths. The +// darwin keychain signer is compiled into every build, so the test cannot rely +// on the binary lacking a signer — it forces a known no-signer state for the +// rejection cases, then registers a stub for the success case. +func TestResolveRegisterAuthMethod(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f := &cmdutil.Factory{} + ctx := context.Background() + + prevSigner := keysigner.Active() + t.Cleanup(func() { keysigner.Register(prevSigner) }) + keysigner.Register(nil) + + if m, err := resolveRegisterAuthMethod(ctx, f, core.AuthMethodClientSecret); err != nil || m != core.AuthMethodClientSecret { + t.Errorf("client_secret: got (%q, %v), want (client_secret, nil)", m, err) + } + + if m, err := resolveRegisterAuthMethod(ctx, f, ""); err != nil || m != core.AuthMethodClientSecret { + t.Errorf("default: got (%q, %v), want (client_secret, nil)", m, err) + } + + if _, err := resolveRegisterAuthMethod(ctx, f, "bogus"); err == nil { + t.Error("bogus auth-method: expected error") + } + + if _, err := resolveRegisterAuthMethod(ctx, f, core.AuthMethodPrivateKeyJWT); err == nil { + t.Error("private_key_jwt without a signer: expected error") + } + + keysigner.Register(authMethodTestSigner{info: keysigner.HardwareInfo{Backend: "tpm2", Available: true}}) + + if m, err := resolveRegisterAuthMethod(ctx, f, core.AuthMethodPrivateKeyJWT); err != nil || m != core.AuthMethodPrivateKeyJWT { + t.Errorf("private_key_jwt with signer: got (%q, %v), want (private_key_jwt, nil)", m, err) + } + + f.IOStreams = &cmdutil.IOStreams{IsTerminal: true} + if m, err := resolveRegisterAuthMethod(ctx, f, ""); err != nil || m != core.AuthMethodClientSecret { + t.Errorf("default with terminal signer: got (%q, %v), want (client_secret, nil)", m, err) + } +} + +func TestConfigInitRunRejectsPrivateKeyJWTIncompatibleModes(t *testing.T) { + tests := []struct { + name string + configure func(*ConfigInitOptions, *cmdutil.Factory) + wantTarget string + }{ + { + name: "app id import", + configure: func(opts *ConfigInitOptions, _ *cmdutil.Factory) { + opts.AppID = "cli_test" + }, + wantTarget: "--app-id", + }, + { + name: "app secret stdin import", + configure: func(opts *ConfigInitOptions, f *cmdutil.Factory) { + opts.AppSecretStdin = true + f.IOStreams.In = strings.NewReader("secret\n") + }, + wantTarget: "--app-secret-stdin", + }, + { + name: "restore", + configure: func(opts *ConfigInitOptions, _ *cmdutil.Factory) { + opts.Restore = true + }, + wantTarget: "--restore", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + opts := &ConfigInitOptions{ + Factory: f, + Ctx: context.Background(), + PrivateKeyJWT: true, + } + tc.configure(opts, f) + + err := configInitRun(opts) + if err == nil { + t.Fatal("expected incompatible mode error") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("error is not typed: %T %[1]v", err) + } + if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error = %T, want *errs.ValidationError", err) + } + if validationErr.Param != "--private-key-jwt" { + t.Fatalf("param = %q, want --private-key-jwt", validationErr.Param) + } + if !strings.Contains(problem.Message, tc.wantTarget) { + t.Fatalf("message = %q, want %s", problem.Message, tc.wantTarget) + } + }) + } +} + +func TestResolveRegisterAuthMethod_PrivateKeyJWTRejectsUnavailableHardware(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + prevSigner := keysigner.Active() + t.Cleanup(func() { keysigner.Register(prevSigner) }) + keysigner.Register(authMethodTestSigner{info: keysigner.HardwareInfo{ + Backend: "tpm2", + Reason: "open /dev/tpmrm0: permission denied", + }}) + + _, err := resolveRegisterAuthMethod(context.Background(), &cmdutil.Factory{}, core.AuthMethodPrivateKeyJWT) + if err == nil { + t.Fatal("private_key_jwt with unavailable signer hardware: expected error") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("error is not typed: %T %[1]v", err) + } + if problem.Category != errs.CategoryConfig || problem.Subtype != errs.SubtypeInvalidClient { + t.Fatalf("problem = %s/%s, want config/invalid_client", problem.Category, problem.Subtype) + } + wantMessage := "this machine does not support --private-key-jwt" + if problem.Message != wantMessage { + t.Fatalf("message = %q, want %q", problem.Message, wantMessage) + } + if strings.Contains(problem.Message, "sks") || strings.Contains(problem.Message, "/dev/tpm") || strings.Contains(problem.Message, "tpm") || strings.Contains(problem.Message, "TEE") || strings.Contains(problem.Message, "Keychain") { + t.Fatalf("message exposes backend detail: %q", problem.Message) + } + if !strings.Contains(problem.Hint, "omit --private-key-jwt") { + t.Fatalf("hint = %q, want guidance to omit --private-key-jwt", problem.Hint) + } + if strings.Contains(problem.Hint, "fix the local signer") { + t.Fatalf("hint exposes unnecessary signer recovery: %q", problem.Hint) + } +} + +func TestResolveRegisterAuthMethod_PrivateKeyJWTRejectsProbeError(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + probeErr := errors.New("probe exploded") + prevSigner := keysigner.Active() + t.Cleanup(func() { keysigner.Register(prevSigner) }) + keysigner.Register(authMethodTestSigner{ + info: keysigner.HardwareInfo{Backend: "keychain"}, + probeErr: probeErr, + }) + + _, err := resolveRegisterAuthMethod(context.Background(), &cmdutil.Factory{}, core.AuthMethodPrivateKeyJWT) + if err == nil { + t.Fatal("private_key_jwt with probe error: expected error") + } + if !errors.Is(err, probeErr) { + t.Fatalf("error does not preserve probe cause: %v", err) + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("error is not typed: %T %[1]v", err) + } + if problem.Category != errs.CategoryConfig || problem.Subtype != errs.SubtypeInvalidClient { + t.Fatalf("problem = %s/%s, want config/invalid_client", problem.Category, problem.Subtype) + } + wantMessage := "this machine does not support --private-key-jwt" + if problem.Message != wantMessage { + t.Fatalf("message = %q, want %q", problem.Message, wantMessage) + } + if strings.Contains(problem.Message, "probe") || strings.Contains(problem.Message, "keychain signer") { + t.Fatalf("message exposes probe detail: %q", problem.Message) + } + if !strings.Contains(problem.Hint, "omit --private-key-jwt") { + t.Fatalf("hint = %q, want guidance to omit --private-key-jwt", problem.Hint) + } + if strings.Contains(problem.Hint, "fix the local signer") { + t.Fatalf("hint exposes unnecessary signer recovery: %q", problem.Hint) + } +} + +func TestConfigInitRun_PrivateKeyJWTRejectsBeforeInteractiveMode(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + prevSigner := keysigner.Active() + t.Cleanup(func() { keysigner.Register(prevSigner) }) + keysigner.Register(authMethodTestSigner{info: keysigner.HardwareInfo{ + Backend: "tpm2", + Reason: "not available", + }}) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + f.IOStreams.IsTerminal = true + opts := &ConfigInitOptions{ + Factory: f, + Ctx: context.Background(), + PrivateKeyJWT: true, + Lang: "zh_cn", + UILang: "zh_cn", + } + + err := configInitRun(opts) + if err == nil { + t.Fatal("config init --private-key-jwt on unsupported machine: expected error before interactive mode") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("error is not typed: %T %[1]v", err) + } + if problem.Category != errs.CategoryConfig || problem.Subtype != errs.SubtypeInvalidClient { + t.Fatalf("problem = %s/%s, want config/invalid_client", problem.Category, problem.Subtype) + } + if problem.Message != "this machine does not support --private-key-jwt" { + t.Fatalf("message = %q", problem.Message) + } +} + +func TestExistingAppRequiresSecret(t *testing.T) { + if !existingAppRequiresSecret(core.AuthMethodClientSecret) { + t.Error("client_secret existing app should require App Secret") + } + if existingAppRequiresSecret("") != true { + t.Error("default existing app should require App Secret") + } + if existingAppRequiresSecret(core.AuthMethodPrivateKeyJWT) { + t.Error("private_key_jwt existing app should not require App Secret") + } +} + +// TestValidatePKJWTKeyBinding covers the guard that rejects a registration +// resolving to private_key_jwt with no signing key bound (e.g. an existing +// secret-based app was selected on the confirm page). +func TestValidatePKJWTKeyBinding(t *testing.T) { + if err := validatePKJWTKeyBinding(core.AuthMethodPrivateKeyJWT, ""); err == nil { + t.Error("pkjwt with empty keyLabel: expected error") + } + if err := validatePKJWTKeyBinding(core.AuthMethodPrivateKeyJWT, "agent-key"); err != nil { + t.Errorf("pkjwt with keyLabel: expected nil, got %v", err) + } + if err := validatePKJWTKeyBinding(core.AuthMethodClientSecret, ""); err != nil { + t.Errorf("client_secret: expected nil, got %v", err) + } +} + +// TestResolveFinalAuthMethod locks the authoritative-method logic. The 2nd case +// is the real bug: we requested private_key_jwt but the server resolved to an +// existing client_secret app — we must persist client_secret, not pkjwt. +func TestResolveFinalAuthMethod(t *testing.T) { + if m := resolveFinalAuthMethod([]string{"client_secret", "private_key_jwt"}, core.AuthMethodClientSecret); m != core.AuthMethodPrivateKeyJWT { + t.Errorf("prefers private_key_jwt: got %q", m) + } + if m := resolveFinalAuthMethod([]string{"client_secret"}, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodClientSecret { + t.Errorf("server client_secret must override requested pkjwt: got %q", m) + } + if m := resolveFinalAuthMethod(nil, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodPrivateKeyJWT { + t.Errorf("fallback to requested when server is silent: got %q", m) + } + // Explicit empty slice (not just nil) also falls back to requested — the same + // len()==0 back-compat allowance the init guard relies on to let private_key_jwt + // proceed against an older server (see internal/auth + // TestRequestAppRegistrationInit_EmptySupportedAuthMethods). + if m := resolveFinalAuthMethod([]string{}, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodPrivateKeyJWT { + t.Errorf("empty []string should fall back to requested private_key_jwt: got %q", m) + } + if m := resolveFinalAuthMethod(nil, ""); m != core.AuthMethodClientSecret { + t.Errorf("default to client_secret: got %q", m) + } +} diff --git a/cmd/config/init_interactive.go b/cmd/config/init_interactive.go index ecc3ef0201..04710776b3 100644 --- a/cmd/config/init_interactive.go +++ b/cmd/config/init_interactive.go @@ -8,6 +8,9 @@ import ( "errors" "fmt" "net" + "slices" + "strings" + "time" "github.com/charmbracelet/huh" "github.com/larksuite/cli/internal/build" @@ -15,22 +18,26 @@ import ( "github.com/larksuite/cli/errs" larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/auth/jwt" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/keysigner" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/transport" ) // configInitResult holds the result of the interactive config init flow. type configInitResult struct { - Mode string // "create" or "existing" - Brand core.LarkBrand - AppID string - AppSecret string + Mode string // "create" or "existing" + Brand core.LarkBrand + AppID string + AppSecret string + AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT + KeyLabel string // TEE key handle when AuthMethod == private_key_jwt } // runInteractiveConfigInit shows an interactive TUI for config init. -func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) { +func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, authMethodFlag string, msg *initMsg) (*configInitResult, error) { // Phase 1: Choose mode var mode string form1 := huh.NewForm( @@ -53,14 +60,18 @@ func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, msg *init } if mode == "existing" { - return runExistingAppForm(f, msg) + return runExistingAppForm(ctx, f, authMethodFlag, msg) } - return runCreateAppFlow(ctx, f, "", msg) + return runCreateAppFlow(ctx, f, "", authMethodFlag, msg, "") +} + +func existingAppRequiresSecret(requestedAuthMethod string) bool { + return requestedAuthMethod != core.AuthMethodPrivateKeyJWT } // runExistingAppForm shows a huh form for manually entering App ID / App Secret / Brand. -func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) { +func runExistingAppForm(ctx context.Context, f *cmdutil.Factory, requestedAuthMethod string, msg *initMsg) (*configInitResult, error) { // Load existing config for defaults existing, _ := core.LoadMultiAppConfig() var firstApp *core.AppConfig @@ -94,19 +105,31 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er brand = string(firstApp.Brand) } - form := huh.NewForm( - huh.NewGroup( - appIDInput, - appSecretInput, - huh.NewSelect[string](). - Title(msg.Platform). - Options( - huh.NewOption(msg.Feishu, "feishu"), - huh.NewOption("Lark", "lark"), - ). - Value(&brand), - ), - ).WithTheme(cmdutil.ThemeFeishu()) + brandSelect := huh.NewSelect[string](). + Title(msg.Platform). + Options( + huh.NewOption(msg.Feishu, "feishu"), + huh.NewOption("Lark", "lark"), + ). + Value(&brand) + + var form *huh.Form + if existingAppRequiresSecret(requestedAuthMethod) { + form = huh.NewForm( + huh.NewGroup( + appIDInput, + appSecretInput, + brandSelect, + ), + ).WithTheme(cmdutil.ThemeFeishu()) + } else { + form = huh.NewForm( + huh.NewGroup( + appIDInput, + brandSelect, + ), + ).WithTheme(cmdutil.ThemeFeishu()) + } if err := form.Run(); err != nil { if err == huh.ErrUserAborted { @@ -119,6 +142,13 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er if appID == "" && firstApp != nil { appID = firstApp.AppId } + if !existingAppRequiresSecret(requestedAuthMethod) { + if appID == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID cannot be empty"). + WithParam("--app-id") + } + return runCreateAppFlow(ctx, f, parseBrand(brand), core.AuthMethodPrivateKeyJWT, msg, appID) + } if appSecret == "" && firstApp != nil && !firstApp.AppSecret.IsZero() { // Keep existing secret - caller will handle return &configInitResult{ @@ -148,9 +178,49 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er }, nil } +// resolveRegisterAuthMethod decides the auth method for a new-app registration. +// An explicit private_key_jwt request wins; otherwise the default is +// client_secret with no extra prompt. +func resolveRegisterAuthMethod(ctx context.Context, _ *cmdutil.Factory, requested string) (string, error) { + const pkjwtUnsupportedMessage = "this machine does not support --private-key-jwt" + + switch requested { + case core.AuthMethodPrivateKeyJWT: + info, ok, err := keysigner.ProbeActiveHardware(ctx) + if !ok { + return "", errs.NewConfigError(errs.SubtypeInvalidClient, + pkjwtUnsupportedMessage). + WithHint("omit --private-key-jwt to register with an app secret") + } + if err != nil { + return "", errs.NewConfigError(errs.SubtypeInvalidClient, + pkjwtUnsupportedMessage). + WithCause(err). + WithHint("omit --private-key-jwt to register with an app secret") + } + if !info.Available { + return "", errs.NewConfigError(errs.SubtypeInvalidClient, + pkjwtUnsupportedMessage). + WithHint("omit --private-key-jwt to register with an app secret") + } + return core.AuthMethodPrivateKeyJWT, nil + case core.AuthMethodClientSecret: + return core.AuthMethodClientSecret, nil + case "": + return core.AuthMethodClientSecret, nil + default: + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "unknown auth method %q (use client_secret or private_key_jwt)", requested) + } +} + // runCreateAppFlow runs the "create new app" flow via OpenClaw device flow. // If brandOverride is non-empty, skip the interactive brand selection. -func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) { +// requestedAuthMethod is the requested auth method; empty means client_secret. +// restoreAppID, when non-empty, is sent on the registration begin request so the +// server re-registers that existing app (credential recovery) instead of creating +// a new one. Empty preserves the normal new-app flow. +func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, requestedAuthMethod string, msg *initMsg, restoreAppID string) (*configInitResult, error) { var larkBrand core.LarkBrand if brandOverride != "" { larkBrand = brandOverride @@ -178,17 +248,57 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor larkBrand = parseBrand(brand) } - // Step 1: Request app registration (begin) + authMethod, err := resolveRegisterAuthMethod(ctx, f, requestedAuthMethod) + if err != nil { + return nil, err + } + + // Step 1: Request app registration (begin). // Use the shared proxy-plugin-aware transport so registration traffic is not // a bypass of proxy plugin mode. httpClient := transport.NewHTTPClient(0) - authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, f.IOStreams.ErrOut) + + // For private_key_jwt: init to obtain a nonce, then sign a TEE attestation + // (carrying the public key in its jwk header) to send with begin. + beginOpts := larkauth.AppRegistrationBeginOptions{} + keyLabel := "" + if authMethod == core.AuthMethodPrivateKeyJWT { + initResp, initErr := larkauth.RequestAppRegistrationInit(ctx, httpClient) + if initErr != nil { + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration init failed: %v", initErr).WithCause(initErr) + } + // An empty SupportedAuthMethods is intentionally treated as "older server / + // unknown": len()==0 makes this guard false, so the requested + // private_key_jwt proceeds. This mirrors resolveFinalAuthMethod's + // back-compat fallback to the requested method. Only an explicit list that + // omits private_key_jwt rejects here. + if len(initResp.SupportedAuthMethods) > 0 && !slices.Contains(initResp.SupportedAuthMethods, core.AuthMethodPrivateKeyJWT) { + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, + "server does not support private_key_jwt for this app type (supported: %s)", strings.Join(initResp.SupportedAuthMethods, ", ")). + WithHint("omit --private-key-jwt to register with an app secret instead") + } + keyLabel = keysigner.DefaultKeyLabel + signer := keysigner.Active() // non-nil, guaranteed by resolveRegisterAuthMethod + attestation, signErr := jwt.SignAttestation(ctx, signer, keysigner.KeyRef{Label: keyLabel}, initResp.Nonce, time.Now()) + if signErr != nil { + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to sign registration attestation: %v", signErr).WithCause(signErr) + } + beginOpts = larkauth.AppRegistrationBeginOptions{ + AuthMethod: core.AuthMethodPrivateKeyJWT, + AuthAttestation: attestation, + } + } + + // Restore flow: re-register the existing app instead of creating a new one. + beginOpts.RestoreAppID = restoreAppID + + authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, beginOpts, f.IOStreams.ErrOut) if err != nil { return nil, classifyRegistrationBeginError(err) } // Step 2: Build and display verification URL + QR code - verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version) + verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version, restoreAppID) // Branch on TTY: human-friendly copy in interactive terminals, // preserve original copy for AI / non-interactive callers. @@ -217,18 +327,42 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor return nil, classifyRegistrationError(err) } - if result.ClientID == "" || result.ClientSecret == "" { - return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret") + // The final auth method is decided by the user/admin at confirmation and + // returned by poll — NOT necessarily what we requested. Selecting an existing + // client_secret app, for example, yields client_secret even though we sent + // private_key_jwt. Trust the result so we persist the truth. + finalMethod := resolveFinalAuthMethod(result.AuthMethods, authMethod) + + if result.ClientID == "" { + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing app_id") + } + if finalMethod != core.AuthMethodPrivateKeyJWT && result.ClientSecret == "" { + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_secret") } + // Surface a downgrade: requested private_key_jwt but the app resolved to a + // secret-based method (e.g. an existing app was selected). The key was NOT + // bound, so we must store the secret method, not private_key_jwt. + if authMethod == core.AuthMethodPrivateKeyJWT && finalMethod != core.AuthMethodPrivateKeyJWT { + fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] note: requested private_key_jwt, but the app uses %q (e.g. an existing app was selected); storing %q.\n", finalMethod, finalMethod) + } fmt.Fprintln(f.IOStreams.ErrOut) output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, result.ClientID)) + keyToStore := "" + if finalMethod == core.AuthMethodPrivateKeyJWT { + keyToStore = keyLabel + } + if err := validatePKJWTKeyBinding(finalMethod, keyToStore); err != nil { + return nil, err + } return &configInitResult{ - Mode: "create", - Brand: finalBrand, - AppID: result.ClientID, - AppSecret: result.ClientSecret, + Mode: "create", + Brand: finalBrand, + AppID: result.ClientID, + AppSecret: result.ClientSecret, // empty for private_key_jwt; real secret otherwise + AuthMethod: finalMethod, + KeyLabel: keyToStore, }, nil } @@ -268,3 +402,41 @@ func classifyRegistrationError(err error) error { return errs.NewAuthenticationError(errs.SubtypeUnknown, "app registration failed: %v", err).WithCause(err) } } + +// validatePKJWTKeyBinding rejects a registration that resolved to +// private_key_jwt without a signing key bound to it. keyLabel is non-empty only +// when the local flow chose private_key_jwt and signed a TEE attestation; a +// resolved method of private_key_jwt with no key handle would save an unusable +// config (rejected later at config load, surfacing as "saved OK, fails on first +// use"), so it is caught here at registration time instead. +func validatePKJWTKeyBinding(finalMethod, keyLabel string) error { + if finalMethod == core.AuthMethodPrivateKeyJWT && keyLabel == "" { + return errs.NewConfigError(errs.SubtypeInvalidClient, + "registration resolved to private_key_jwt but no signing key was bound to this app (an existing secret-based app may have been selected)"). + WithHint("re-register with: lark-cli config init --new --private-key-jwt") + } + return nil +} + +// resolveFinalAuthMethod picks the authoritative method from the poll result, +// preferring private_key_jwt, then client_secret. It falls back to the requested +// method when the server returns nothing (older servers). +func resolveFinalAuthMethod(serverMethods []string, requested string) string { + if len(serverMethods) == 0 { + if requested == "" { + return core.AuthMethodClientSecret + } + return requested + } + for _, m := range serverMethods { + if m == core.AuthMethodPrivateKeyJWT { + return core.AuthMethodPrivateKeyJWT + } + } + for _, m := range serverMethods { + if m == core.AuthMethodClientSecret { + return core.AuthMethodClientSecret + } + } + return serverMethods[0] +} diff --git a/cmd/config/init_probe.go b/cmd/config/init_probe.go index b6873ac21a..ed746dd66d 100644 --- a/cmd/config/init_probe.go +++ b/cmd/config/init_probe.go @@ -16,6 +16,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/keysigner" ) // probeTimeout is the total wall-clock budget for the credential probe step @@ -90,3 +91,35 @@ func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret _, _ = io.Copy(io.Discard, resp.Body) return nil } + +// runProbePKJWT does a best-effort key-binding validation after a private_key_jwt +// config is saved: it signs a client_assertion with the local platform key and +// mints a token. A typed error (a deterministic server rejection — e.g. the key +// is not bound to this app) is propagated so `config init` exits non-zero with +// the canonical envelope; untyped errors (transport / HTTP / parse / timeout) +// are swallowed (return nil). The mint itself is the probe — no second call. +func runProbePKJWT(parent context.Context, factory *cmdutil.Factory, brand core.LarkBrand, clientID string, signer keysigner.Signer, keyLabel string) error { + if factory == nil { + return nil + } + if signer == nil { + return nil + } + httpClient, err := factory.HttpClient() + if err != nil { + return nil + } + + ctx, cancel := context.WithTimeout(parent, probeTimeout) + defer cancel() + + if _, err := credential.FetchTATWithAssertion(ctx, httpClient, brand, clientID, signer, keyLabel); err != nil { + // Typed = deterministic credential rejection → propagate. Untyped + // (transport / HTTP / parse / timeout) is ambiguous → stay silent. + if errs.IsTyped(err) { + return err + } + return nil + } + return nil +} diff --git a/cmd/config/init_probe_test.go b/cmd/config/init_probe_test.go index f4156a73b9..29cbb2f5b4 100644 --- a/cmd/config/init_probe_test.go +++ b/cmd/config/init_probe_test.go @@ -6,6 +6,11 @@ package config import ( "bytes" "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + crand "crypto/rand" + "crypto/sha256" "errors" "io" "net/http" @@ -17,14 +22,17 @@ import ( "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/keysigner" ) // fakeRT routes requests to per-path handlers and records what it saw. type fakeRT struct { tatHandler func(req *http.Request) (*http.Response, error) probeHandler func(req *http.Request) (*http.Response, error) + oauthHandler func(req *http.Request) (*http.Response, error) tatCalls int probeCalls int + oauthCalls int probeReq *http.Request probeBody string } @@ -48,10 +56,50 @@ func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) { return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil } return f.probeHandler(req) + case strings.HasSuffix(req.URL.Path, "/authen/v2/oauth/token"): + f.oauthCalls++ + if f.oauthHandler == nil { + return jsonResp(200, `{"access_token":"test-token"}`), nil + } + return f.oauthHandler(req) } return nil, errors.New("unexpected URL: " + req.URL.String()) } +// probeTestSigner is an in-memory real ECDSA P-256 signer used to sign the +// client_assertion in runProbePKJWT tests (authMethodTestSigner returns a nil +// key and cannot sign). +type probeTestSigner struct{ key *ecdsa.PrivateKey } + +func newProbeTestSigner(t *testing.T) *probeTestSigner { + t.Helper() + k, err := ecdsa.GenerateKey(elliptic.P256(), crand.Reader) + if err != nil { + t.Fatal(err) + } + return &probeTestSigner{key: k} +} + +func (p *probeTestSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) { + return p.key.Public(), nil +} + +func (p *probeTestSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) { + return p.key.Public(), nil +} + +func (p *probeTestSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) { + h := sha256.Sum256(in) + r, s, err := ecdsa.Sign(crand.Reader, p.key, h[:]) + if err != nil { + return nil, "", err + } + sig := make([]byte, 64) + r.FillBytes(sig[:32]) + s.FillBytes(sig[32:]) + return sig, keysigner.AlgES256, nil +} + func jsonResp(code int, body string) *http.Response { return &http.Response{ StatusCode: code, @@ -208,10 +256,12 @@ func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) { assertSilent(t, err, errBuf) } -func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) { +func TestProbeInitResult_ClientSecret(t *testing.T) { rt := &fakeRT{} f, errBuf := fakeFactory(t, rt) - err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu) + opts := &ConfigInitOptions{Ctx: context.Background()} + result := &configInitResult{AppID: "cli_x", AppSecret: "test-secret", Brand: core.BrandFeishu} + err := probeInitResult(opts, f, result) if rt.tatCalls != 1 || rt.probeCalls != 1 { t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls) } @@ -285,3 +335,47 @@ func TestRunProbe_TimeoutHonored(t *testing.T) { // must stay silent and not block. assertSilent(t, err, errBuf) } + +// runProbePKJWT: a deterministic server rejection (invalid_client) is propagated +// as a typed ConfigError so config init exits non-zero. +func TestRunProbePKJWT_DeterministicReject_Propagates(t *testing.T) { + rt := &fakeRT{oauthHandler: func(*http.Request) (*http.Response, error) { + return jsonResp(401, `{"error":"invalid_client","error_description":"unknown key"}`), nil + }} + f, errBuf := fakeFactory(t, rt) + err := runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", newProbeTestSigner(t), "agent-key") + if err == nil || !errs.IsTyped(err) { + t.Fatalf("expected propagated typed error, got %T %v", err, err) + } + if errBuf.Len() != 0 { + t.Errorf("runProbePKJWT must not write stderr, got %q", errBuf.String()) + } +} + +// runProbePKJWT: ambiguous upstream noise (HTTP 503) is swallowed — silent, exit 0. +func TestRunProbePKJWT_Ambiguous_Silent(t *testing.T) { + rt := &fakeRT{oauthHandler: func(*http.Request) (*http.Response, error) { + return jsonResp(503, `unavailable`), nil + }} + f, errBuf := fakeFactory(t, rt) + assertSilent(t, runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", newProbeTestSigner(t), "agent-key"), errBuf) +} + +// probeInitResult dispatches private_key_jwt to the assertion-backed probe. +func TestProbeInitResult_PrivateKeyJWT(t *testing.T) { + rt := &fakeRT{} // default oauth handler returns 200 + access_token + f, errBuf := fakeFactory(t, rt) + previous := keysigner.Active() + keysigner.Register(newProbeTestSigner(t)) + t.Cleanup(func() { keysigner.Register(previous) }) + opts := &ConfigInitOptions{Ctx: context.Background()} + result := &configInitResult{AppID: "cli_x", AuthMethod: core.AuthMethodPrivateKeyJWT, KeyLabel: "agent-key", Brand: core.BrandFeishu} + assertSilent(t, probeInitResult(opts, f, result), errBuf) +} + +// runProbePKJWT: a nil signer is a defensive no-op (should not be reached, must +// not panic). +func TestRunProbePKJWT_NilSigner_Silent(t *testing.T) { + f, errBuf := fakeFactory(t, &fakeRT{}) + assertSilent(t, runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", nil, "k"), errBuf) +} diff --git a/cmd/config/init_test.go b/cmd/config/init_test.go index 7347b8b528..ca13fd13ee 100644 --- a/cmd/config/init_test.go +++ b/cmd/config/init_test.go @@ -10,9 +10,25 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/output" ) +// TestRunRestoreFlow_NothingToRestore covers the early guards that return before +// any network/registration call: no config at all, and a config whose resolved +// app has no app id (nothing to send on begin). +func TestRunRestoreFlow_NothingToRestore(t *testing.T) { + // No config on disk. + if err := runRestoreFlow(&ConfigInitOptions{}, nil, nil, nil); err == nil { + t.Fatal("expected error when there is no config to restore") + } + // Config present but the resolved app has no app id. + existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: ""}}} + if err := runRestoreFlow(&ConfigInitOptions{}, existing, nil, nil); err == nil { + t.Fatal("expected error when the resolved app has no app id") + } +} + // updateExistingProfileWithoutSecret guards four blank-input scenarios. Each // must surface as *ValidationError(SubtypeInvalidArgument) per RFC 6749 §5.2: // SubtypeInvalidClient is reserved for IAM rejection of malformed credentials, @@ -119,3 +135,58 @@ func assertValidationParam(t *testing.T, err error, wantParam string) { t.Errorf("Param = %q, want %q", valErr.Param, wantParam) } } + +// countingKeychain is an in-memory KeychainAccess that records whether Remove +// was invoked, so the stale-secret cleanup can be asserted without a real OS +// keychain. +type countingKeychain struct { + store map[string]string + removeCalled bool +} + +func newCountingKeychain() *countingKeychain { + return &countingKeychain{store: map[string]string{}} +} + +func (k *countingKeychain) Get(service, account string) (string, error) { + v, ok := k.store[service+"/"+account] + if !ok { + return "", keychain.ErrNotFound + } + return v, nil +} + +func (k *countingKeychain) Set(service, account, value string) error { + k.store[service+"/"+account] = value + return nil +} + +func (k *countingKeychain) Remove(service, account string) error { + k.removeCalled = true + delete(k.store, service+"/"+account) + return nil +} + +func TestRemoveStaleSecretForPKJWT_SameAppID(t *testing.T) { + kc := newCountingKeychain() + ref, err := core.ForStorage("cli_same", core.PlainSecret("old-secret"), kc) // → Source:"keychain" + if err != nil { + t.Fatal(err) + } + existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_same", AppSecret: ref}}} + removeStaleSecretForPKJWT(existing, "", "cli_same", kc) + if !kc.removeCalled { + t.Error("same appId with keychain secret: expected kc.Remove to be invoked") + } +} + +func TestRemoveStaleSecretForPKJWT_DifferentAppID(t *testing.T) { + kc := newCountingKeychain() + ref, _ := core.ForStorage("cli_old", core.PlainSecret("old-secret"), kc) + kc.removeCalled = false // ForStorage does not call Remove, but reset to be safe + existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_old", AppSecret: ref}}} + removeStaleSecretForPKJWT(existing, "", "cli_new", kc) + if kc.removeCalled { + t.Error("different appId: must NOT remove") + } +} diff --git a/cmd/config/keyless_bind.go b/cmd/config/keyless_bind.go new file mode 100644 index 0000000000..1a10afffa2 --- /dev/null +++ b/cmd/config/keyless_bind.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/keylessprovider" + "github.com/larksuite/cli/internal/keysigner" +) + +const keylessBindProbeTimeout = 12 * time.Second + +var fetchTATForBind = fetchTATForFreshBind + +func fetchTATForFreshBind(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, clientID string, signer keysigner.Signer, provider, keyRef string) (string, func() error, error) { + helper, commitProviderManifest, err := keylessprovider.PrepareRefresh(ctx, provider) + if err != nil { + return "", nil, err + } + token, err := credential.FetchTATWithAssertionWithHelper(ctx, httpClient, brand, clientID, signer, helper, keyRef) + if err != nil { + return "", nil, err + } + return token, commitProviderManifest, nil +} + +// validateBindResult proves that an OpenClaw keyless account can be used by +// the exact helper/keyRef/appID tuple that will be persisted. Minting a TAT is +// intentional: pubkey alone only proves that the helper runs (and some signer +// implementations create a missing key during pubkey); a successful token mint +// proves that this public key is already registered to the selected app, so no +// attach flow or second user authorization is needed. +func validateBindResult(parent context.Context, opts *BindOptions, result *BindResult) error { + if result == nil || result.AppConfig == nil { + return errs.NewInternalError(errs.SubtypeSDKError, "config bind produced no app configuration") + } + app := result.AppConfig + if app.AuthMethod != core.AuthMethodPrivateKeyJWT { + return nil + } + if app.KeyRef == nil || app.KeyRef.ID == "" { + return errs.NewConfigError(errs.SubtypeInvalidConfig, + "private_key_jwt bind for app %s is missing keyRef", app.AppId) + } + if strings.TrimSpace(app.KeyRef.Provider) != core.KeylessProviderLarkSuite { + return errs.NewConfigError(errs.SubtypeInvalidClient, + "OpenClaw private_key_jwt bind for app %s did not select provider %s", app.AppId, core.KeylessProviderLarkSuite) + } + if opts == nil || opts.Factory == nil || opts.Factory.HttpClient == nil { + return errs.NewInternalError(errs.SubtypeSDKError, "cannot validate keyless bind without an HTTP client") + } + httpClient, err := opts.Factory.HttpClient() + if err != nil { + return errs.NewNetworkError(errs.SubtypeNetworkTransport, + "cannot create HTTP client for keyless bind validation: %v", err).WithCause(err) + } + + ctx, cancel := context.WithTimeout(parent, keylessBindProbeTimeout) + defer cancel() + _, commitProviderManifest, err := fetchTATForBind( + ctx, httpClient, app.Brand, app.AppId, keysigner.Active(), app.KeyRef.Provider, app.KeyRef.ID, + ) + if err != nil { + if errs.IsTyped(err) { + return err + } + return errs.NewConfigError(errs.SubtypeInvalidClient, + "OpenClaw signer could not authenticate app %s: %v", app.AppId, err). + WithHint("repair or reinstall the OpenClaw Feishu plugin and its platform signer dependency, verify the keyless account, then retry config bind"). + WithCause(err) + } + if commitProviderManifest == nil { + return errs.NewInternalError(errs.SubtypeStorage, + "OpenClaw signer validation did not produce a provider manifest commit") + } + result.commitProviderManifest = commitProviderManifest + return nil +} diff --git a/cmd/config/keyless_bind_test.go b/cmd/config/keyless_bind_test.go new file mode 100644 index 0000000000..2e8b48ab31 --- /dev/null +++ b/cmd/config/keyless_bind_test.go @@ -0,0 +1,438 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/i18n" + "github.com/larksuite/cli/internal/keysigner" +) + +func TestConfigBindRun_OpenClawKeylessWritesProviderWithoutPath(t *testing.T) { + saveWorkspace(t) + clearAgentEnv(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + writeOpenClawKeylessConfig(t, "cli_keyless", "openclaw-lark") + + var gotProvider, gotKeyRef, gotClientID string + var providerCommits int + var providerCommitSawWorkspace bool + replaceBindProbe(t, func(_ context.Context, _ *http.Client, _ core.LarkBrand, clientID string, _ keysigner.Signer, provider, keyRef string) (string, func() error, error) { + gotClientID, gotProvider, gotKeyRef = clientID, provider, keyRef + return "tat-ok", func() error { + providerCommits++ + data, err := os.ReadFile(core.GetConfigPath()) + if err != nil { + return err + } + providerCommitSawWorkspace = strings.Contains(string(data), "cli_keyless") + return nil + }, nil + }) + + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", Identity: "bot-only"}); err != nil { + t.Fatalf("configBindRun: %v", err) + } + if gotClientID != "cli_keyless" || gotProvider != core.KeylessProviderLarkSuite || gotKeyRef != "openclaw-lark" { + t.Fatalf("probe route = client %q provider %q keyRef %q", gotClientID, gotProvider, gotKeyRef) + } + multi, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatal(err) + } + app := multi.CurrentAppConfig("") + if app == nil || app.AuthMethod != core.AuthMethodPrivateKeyJWT || app.KeyRef == nil || + app.KeyRef.Provider != core.KeylessProviderLarkSuite || app.KeyRef.ID != "openclaw-lark" || !app.AppSecret.IsZero() { + t.Fatalf("persisted app = %#v", app) + } + if stdout.Len() == 0 { + t.Fatal("bind did not emit success envelope") + } + if providerCommits != 1 { + t.Fatalf("provider manifest commits = %d, want 1", providerCommits) + } + if !providerCommitSawWorkspace { + t.Fatal("provider manifest committed before the workspace config became readable") + } +} + +func TestConfigBindRun_OpenClawOptionalSignerClosedLoop(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test helper uses a POSIX shebang; Windows resolution is compile-checked separately") + } + saveWorkspace(t) + clearAgentEnv(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + signerPath := installOpenClawOptionalSigner(t) + writeOpenClawKeylessConfig(t, "cli_keyless_optional", "openclaw-lark") + + f, _, _, registry := cmdutil.TestFactory(t, nil) + registry.Register(&httpmock.Stub{ + Method: http.MethodPost, + URL: auth.PathOAuthTokenV2, + Body: map[string]any{"code": 0, "access_token": "tat-from-optional-signer"}, + BodyFilter: func(body []byte) bool { + form, err := url.ParseQuery(string(body)) + return err == nil && + form.Get("client_id") == "cli_keyless_optional" && + form.Get("client_assertion_type") == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" && + form.Get("client_assertion") == "optional.jwt" && + !form.Has("client_secret") + }, + }) + + if err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", Identity: "bot-only"}); err != nil { + t.Fatalf("configBindRun: %v", err) + } + data, err := os.ReadFile(core.GetConfigPath()) + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(string(data), "\n") || !strings.Contains(string(data), "\n \"apps\": [") { + t.Fatalf("config is not formatted JSON with a trailing newline:\n%s", data) + } + if strings.Contains(string(data), signerPath) { + t.Fatalf("config persisted the discovered signer executable path:\n%s", data) + } + providerData, err := os.ReadFile(filepath.Join(core.GetBaseConfigDir(), "signing-providers.json")) + if err != nil { + t.Fatalf("read global signer manifest: %v", err) + } + if !strings.Contains(string(providerData), signerPath) { + t.Fatalf("global signer manifest did not record the verified executable") + } + multi, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatal(err) + } + app := multi.CurrentAppConfig("") + if app == nil || app.AppId != "cli_keyless_optional" || app.KeyRef == nil || + app.KeyRef.Provider != core.KeylessProviderLarkSuite || app.KeyRef.ID != "openclaw-lark" || !app.AppSecret.IsZero() { + t.Fatalf("resolved config = %#v", app) + } +} + +func TestConfigBindRun_OpenClawKeylessProbeFailureDoesNotWrite(t *testing.T) { + saveWorkspace(t) + clearAgentEnv(t) + base := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", base) + writeOpenClawKeylessConfig(t, "cli_wrong_key", "openclaw-lark") + replaceBindProbe(t, func(context.Context, *http.Client, core.LarkBrand, string, keysigner.Signer, string, string) (string, func() error, error) { + return "", nil, errs.NewConfigError(errs.SubtypeInvalidClient, "public key is not bound") + }) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", Identity: "bot-only"}); err == nil { + t.Fatal("expected probe error") + } + if _, err := os.Stat(filepath.Join(base, "openclaw", "config.json")); !os.IsNotExist(err) { + t.Fatalf("config must not be written; stat error = %v", err) + } +} + +func TestConfigBindRun_OpenClawKeylessMissingProviderCommitFailsClosed(t *testing.T) { + saveWorkspace(t) + clearAgentEnv(t) + base := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", base) + writeOpenClawKeylessConfig(t, "cli_missing_commit", "openclaw-lark") + replaceBindProbe(t, func(context.Context, *http.Client, core.LarkBrand, string, keysigner.Signer, string, string) (string, func() error, error) { + return "tat-ok", nil, nil + }) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", Identity: "bot-only"}) + if err == nil || !strings.Contains(err.Error(), "did not produce a provider manifest commit") { + t.Fatalf("configBindRun error = %v", err) + } + if _, statErr := os.Stat(filepath.Join(base, "openclaw", "config.json")); !os.IsNotExist(statErr) { + t.Fatalf("config must not be written; stat error = %v", statErr) + } +} + +func TestCommitBinding_ProviderManifestFailureRestoresWorkspace(t *testing.T) { + saveWorkspace(t) + base := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", base) + core.SetCurrentWorkspace(core.WorkspaceOpenClaw) + configPath := core.GetConfigPath() + if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil { + t.Fatal(err) + } + previous := []byte("{\n \"current_app\": \"old\",\n \"apps\": [{\"name\": \"old\", \"app_id\": \"cli_old\", \"app_secret\": \"keep\"}]\n}\n") + if err := os.WriteFile(configPath, previous, 0600); err != nil { + t.Fatal(err) + } + + f, stdout, stderr, _ := cmdutil.TestFactory(t, nil) + commitCalls := 0 + result := &BindResult{ + AppConfig: &core.AppConfig{AppId: "cli_new", Brand: core.BrandFeishu}, + commitProviderManifest: func() error { + commitCalls++ + return errors.New("manifest write failed") + }, + } + err := commitBinding(&BindOptions{Factory: f, Identity: "bot-only"}, result, previous, "openclaw", configPath) + if err == nil || !strings.Contains(err.Error(), "workspace config restored") { + t.Fatalf("commitBinding error = %v", err) + } + if commitCalls != 1 { + t.Fatalf("provider manifest commits = %d, want 1", commitCalls) + } + got, readErr := os.ReadFile(configPath) + if readErr != nil { + t.Fatal(readErr) + } + if string(got) != string(previous) { + t.Fatalf("workspace was not restored:\n%s", got) + } + if stdout.Len() != 0 || stderr.Len() != 0 { + t.Fatalf("failed bind emitted success output: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } +} + +func TestCommitBinding_ProviderManifestFailureRemovesNewWorkspace(t *testing.T) { + saveWorkspace(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + core.SetCurrentWorkspace(core.WorkspaceOpenClaw) + configPath := core.GetConfigPath() + f, stdout, stderr, _ := cmdutil.TestFactory(t, nil) + result := &BindResult{ + AppConfig: &core.AppConfig{AppId: "cli_new", Brand: core.BrandFeishu}, + commitProviderManifest: func() error { + return errors.New("manifest write failed") + }, + } + err := commitBinding(&BindOptions{Factory: f, Identity: "bot-only"}, result, nil, "openclaw", configPath) + if err == nil || !strings.Contains(err.Error(), "workspace config restored") { + t.Fatalf("commitBinding error = %v", err) + } + if _, statErr := os.Stat(configPath); !os.IsNotExist(statErr) { + t.Fatalf("new workspace config was not removed; stat error = %v", statErr) + } + if stdout.Len() != 0 || stderr.Len() != 0 { + t.Fatalf("failed bind emitted success output: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } +} + +func TestCommitBinding_WorkspaceWriteFailureDoesNotCommitProvider(t *testing.T) { + saveWorkspace(t) + base := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", base) + core.SetCurrentWorkspace(core.WorkspaceOpenClaw) + f, _, _, _ := cmdutil.TestFactory(t, nil) + providerCommitted := false + result := &BindResult{ + AppConfig: &core.AppConfig{AppId: "cli_new", Brand: core.BrandFeishu}, + commitProviderManifest: func() error { + providerCommitted = true + return nil + }, + } + configPath := filepath.Join(base, "missing-parent", "config.json") + if err := commitBinding(&BindOptions{Factory: f, Identity: "bot-only"}, result, nil, "openclaw", configPath); err == nil { + t.Fatal("expected workspace write failure") + } + if providerCommitted { + t.Fatal("provider manifest was committed before the workspace config write succeeded") + } +} + +func TestCommitBinding_ConcurrentWorkspaceChangeFailsBeforeProviderCommit(t *testing.T) { + saveWorkspace(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + core.SetCurrentWorkspace(core.WorkspaceOpenClaw) + configPath := core.GetConfigPath() + if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil { + t.Fatal(err) + } + previous := []byte(`{"apps":[{"app_id":"cli_old","app_secret":"old"}]}`) + concurrent := []byte(`{"apps":[{"app_id":"cli_other","app_secret":"newer"}]}`) + if err := os.WriteFile(configPath, concurrent, 0600); err != nil { + t.Fatal(err) + } + f, _, _, _ := cmdutil.TestFactory(t, nil) + providerCommitted := false + result := &BindResult{ + AppConfig: &core.AppConfig{AppId: "cli_new", Brand: core.BrandFeishu}, + commitProviderManifest: func() error { + providerCommitted = true + return nil + }, + } + err := commitBinding(&BindOptions{Factory: f, Identity: "bot-only"}, result, previous, "openclaw", configPath) + if err == nil || !strings.Contains(err.Error(), "changed while the bind was being validated") { + t.Fatalf("commitBinding error = %v", err) + } + if providerCommitted { + t.Fatal("provider manifest was committed after a concurrent workspace change") + } + got, readErr := os.ReadFile(configPath) + if readErr != nil || string(got) != string(concurrent) { + t.Fatalf("concurrent workspace was overwritten: %q, %v", got, readErr) + } +} + +func TestCommitBinding_ProviderFailureDoesNotOverwriteConcurrentWriter(t *testing.T) { + saveWorkspace(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + core.SetCurrentWorkspace(core.WorkspaceOpenClaw) + configPath := core.GetConfigPath() + if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil { + t.Fatal(err) + } + previous := []byte(`{"apps":[{"app_id":"cli_old","app_secret":"old"}]}`) + concurrent := []byte(`{"apps":[{"app_id":"cli_other","app_secret":"newer"}]}`) + if err := os.WriteFile(configPath, previous, 0600); err != nil { + t.Fatal(err) + } + f, stdout, stderr, _ := cmdutil.TestFactory(t, nil) + result := &BindResult{ + AppConfig: &core.AppConfig{AppId: "cli_new", Brand: core.BrandFeishu}, + commitProviderManifest: func() error { + if err := os.WriteFile(configPath, concurrent, 0600); err != nil { + return err + } + return errors.New("manifest write failed") + }, + } + err := commitBinding(&BindOptions{Factory: f, Identity: "bot-only"}, result, previous, "openclaw", configPath) + if err == nil || !strings.Contains(err.Error(), "refusing to overwrite") { + t.Fatalf("commitBinding error = %v", err) + } + got, readErr := os.ReadFile(configPath) + if readErr != nil || string(got) != string(concurrent) { + t.Fatalf("concurrent workspace was overwritten: %q, %v", got, readErr) + } + if stdout.Len() != 0 || stderr.Len() != 0 { + t.Fatalf("failed bind emitted success output: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } +} + +func TestMergeBoundApp_UpsertsAndActivatesWithoutClobberingSiblings(t *testing.T) { + lang := "en_us" + previous := &core.MultiAppConfig{ + StrictMode: core.StrictModeUser, + CurrentApp: "other", + Apps: []core.AppConfig{ + {Name: "bound", AppId: "cli_target", Brand: core.BrandLark, Lang: coreLang(lang), Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "alice"}}}, + {Name: "other", AppId: "cli_other", AppSecret: core.PlainSecret("keep"), Brand: core.BrandFeishu, Users: []core.AppUser{}}, + }, + } + beforeSibling := previous.Apps[1] + data := mustJSON(t, previous) + incoming := &core.AppConfig{AppId: "cli_target", Brand: core.BrandFeishu, AuthMethod: core.AuthMethodPrivateKeyJWT, + KeyRef: &core.SecretRef{Source: core.SecretSourceTEE, Provider: core.KeylessProviderLarkSuite, ID: "openclaw-lark"}} + + got, err := mergeBoundApp(incoming, data, false) + if err != nil { + t.Fatal(err) + } + if len(got.Apps) != 2 || got.CurrentApp != "bound" || got.PreviousApp != "other" || got.StrictMode != previous.StrictMode { + t.Fatalf("merged root = %#v", got) + } + if !reflect.DeepEqual(got.Apps[1], beforeSibling) { + t.Fatalf("sibling changed: got %#v want %#v", got.Apps[1], beforeSibling) + } + if got.Apps[0].Name != "bound" || got.Apps[0].Lang != coreLang(lang) || !reflect.DeepEqual(got.Apps[0].Users, previous.Apps[0].Users) { + t.Fatalf("target-owned fields were lost: %#v", got.Apps[0]) + } +} + +func writeOpenClawKeylessConfig(t *testing.T, appID, keyRef string) { + t.Helper() + path := filepath.Join(t.TempDir(), "openclaw.json") + data := []byte(`{"channels":{"feishu":{"appId":"` + appID + `","authMethod":"private_key_jwt","keyRef":"` + keyRef + `","domain":"feishu"}}}`) + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + t.Setenv("OPENCLAW_CONFIG_PATH", path) +} + +func replaceBindProbe(t *testing.T, fn func(context.Context, *http.Client, core.LarkBrand, string, keysigner.Signer, string, string) (string, func() error, error)) { + t.Helper() + previous := fetchTATForBind + fetchTATForBind = fn + t.Cleanup(func() { fetchTATForBind = previous }) +} + +func mustJSON(t *testing.T, value any) []byte { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return data +} + +func coreLang(value string) i18n.Lang { return i18n.Lang(value) } + +func installOpenClawOptionalSigner(t *testing.T) string { + t.Helper() + // This closure specifically exercises the no-inspect compatibility path; + // keylessprovider tests separately cover authoritative managed-project + // discovery from `openclaw plugins inspect`. + t.Setenv("PATH", "") + type signerPackage struct { + name, npmOS, npmCPU, binary string + } + packages := map[string]signerPackage{ + "darwin/arm64": {"@larksuite/lark-keyless-signer-darwin-arm64", "darwin", "arm64", "lark-keyless-signer"}, + "darwin/amd64": {"@larksuite/lark-keyless-signer-darwin-x64", "darwin", "x64", "lark-keyless-signer"}, + "linux/arm64": {"@larksuite/lark-keyless-signer-linux-arm64", "linux", "arm64", "lark-keyless-signer"}, + "linux/amd64": {"@larksuite/lark-keyless-signer-linux-x64", "linux", "x64", "lark-keyless-signer"}, + } + spec, ok := packages[runtime.GOOS+"/"+runtime.GOARCH] + if !ok { + t.Skipf("no optional signer package for %s/%s", runtime.GOOS, runtime.GOARCH) + return "" + } + + stateDir := filepath.Join(t.TempDir(), "openclaw state") + packageDir := filepath.Join( + stateDir, "extensions", "openclaw-lark", "node_modules", "@larksuite", strings.TrimPrefix(spec.name, "@larksuite/"), + ) + binDir := filepath.Join(packageDir, "bin") + if err := os.MkdirAll(binDir, 0700); err != nil { + t.Fatal(err) + } + packageJSON, err := json.MarshalIndent(map[string]any{ + "name": spec.name, "version": "1.2.3", "os": []string{spec.npmOS}, "cpu": []string{spec.npmCPU}, + }, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(packageDir, "package.json"), append(packageJSON, '\n'), 0600); err != nil { + t.Fatal(err) + } + script := "#!/bin/sh\n" + + "IFS= read -r request\n" + + "printf '%s\\n' '{\"ok\":true,\"client_assertion_type\":\"urn:ietf:params:oauth:client-assertion-type:jwt-bearer\",\"client_assertion\":\"optional.jwt\"}'\n" + signerPath := filepath.Join(binDir, spec.binary) + if err := os.WriteFile(signerPath, []byte(script), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("OPENCLAW_STATE_DIR", stateDir) + t.Setenv("PATH", "") + return signerPath +} diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go index bef93d03a8..b3ff7cd9e1 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "io" "net/http" "os" "sync" @@ -19,6 +20,8 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/identitydiag" + "github.com/larksuite/cli/internal/keylessprovider" + "github.com/larksuite/cli/internal/keysigner" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/transport" "github.com/larksuite/cli/internal/update" @@ -135,6 +138,9 @@ func doctorRun(opts *DoctorOptions) error { checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "")) } + // ── 3b. private_key_jwt / TEE signer (local; runs even with --offline) ── + checks = append(checks, teeSignerCheck(opts.Ctx, cfg)) + // ── 4 & 5. Endpoint reachability ── checks = append(checks, networkChecks(opts.Ctx, opts, ep)...) @@ -148,6 +154,73 @@ func identityCheck(name string, id identitydiag.Identity) checkResult { return warn(name, id.Message, id.Hint) } +const teeUnavailableHint = "ensure the device secure hardware is accessible (Linux TPM: add your user to the 'tss' group or run with sufficient privileges)" + +// teeSignerCheck reports the private_key_jwt signing backend (TEE/TPM) status. +// The probe is local hardware only (no network), so it runs even with --offline; +// in a build without a TEE signer it short-circuits without touching any +// hardware. It is a hard requirement for private_key_jwt apps and purely +// informational for client_secret apps. +func teeSignerCheck(ctx context.Context, cfg *core.CliConfig) checkResult { + usesPKJWT := cfg != nil && cfg.AuthMethod == core.AuthMethodPrivateKeyJWT + if usesPKJWT && cfg.KeyProvider != "" { + helper, err := keylessprovider.Resolve(ctx, cfg.KeyProvider) + if err != nil { + return fail("tee_signer", "external keyless signer is unavailable", + fmt.Sprintf("repair or reinstall the OpenClaw Feishu plugin and its platform signer dependency: %v", err)) + } + keyLabel := "" + if cfg != nil { + keyLabel = cfg.KeyLabel + } + if err := helper.Probe(ctx, keyLabel); err != nil { + hint := fmt.Sprintf("fix the configured external keyless signer, or re-run config init to replace/remove it: %v", err) + if usesPKJWT { + return fail("tee_signer", "external keyless signer is unavailable", hint) + } + return warn("tee_signer", "external keyless signer is misconfigured", hint) + } + return pass("tee_signer", "external keyless signer available") + } + info, ok, err := keysigner.ProbeActiveHardware(ctx) + return teeCheckResult(info, ok, err, usesPKJWT) +} + +// teeCheckResult maps a hardware probe to a doctor check. Split out from +// teeSignerCheck so the full matrix is unit-testable without a TPM. +func teeCheckResult(info keysigner.HardwareInfo, ok bool, probeErr error, usesPKJWT bool) checkResult { + const name = "tee_signer" + + // No signer registered → private_key_jwt is unsupported on this build. + if !ok { + if usesPKJWT { + return fail(name, + "app uses private_key_jwt but this build has no TEE key signer", + "the platform key signer ships by default on macOS, Linux, and Windows/amd64; this platform (e.g. Windows/arm64) has none — use a supported platform or re-register without --private-key-jwt") + } + return skip(name, "no TEE signer in this build (only private_key_jwt is affected; client_secret is unaffected)") + } + + backend := info.Backend + if backend == "" { + backend = "tee" + } + + switch { + case probeErr != nil: + return warn(name, fmt.Sprintf("%s signer present but probe errored: %s", backend, probeErr), "") + case info.Available: + if info.VendorName != "" { + return pass(name, fmt.Sprintf("%s TEE available (%s)", backend, info.VendorName)) + } + return pass(name, fmt.Sprintf("%s TEE available", backend)) + case usesPKJWT: + return fail(name, fmt.Sprintf("%s signer present but TEE unavailable: %s", backend, info.Reason), teeUnavailableHint) + default: + return warn(name, fmt.Sprintf("%s signer present but TEE unavailable: %s", backend, info.Reason), teeUnavailableHint) + } +} + // networkChecks probes Open API and MCP endpoints concurrently. func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult { if opts.Offline { @@ -237,14 +310,90 @@ func finishDoctor(f *cmdutil.Factory, checks []checkResult) error { } } - result := map[string]interface{}{ - "ok": allOK, - "workspace": core.CurrentWorkspace().Display(), - "checks": checks, + workspace := core.CurrentWorkspace().Display() + // A terminal on STDOUT gets a readable report; pipes, redirects, scripts and + // tests keep the stable JSON contract (NO_COLOR disables ANSI styling). + // OutIsTerminal checks stdout specifically — IOStreams.IsTerminal reflects + // stdin, which would wrongly send the human report into `doctor | jq`. + if f.IOStreams.OutIsTerminal { + renderDoctorHuman(f.IOStreams.Out, workspace, checks, allOK, os.Getenv("NO_COLOR") == "") + } else { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{ + "ok": allOK, + "workspace": workspace, + "checks": checks, + }) } - output.PrintJson(f.IOStreams.Out, result) if !allOK { return output.ErrBare(1) } return nil } + +// renderDoctorHuman writes a readable health report: one aligned line per check +// with a colored status tag, an indented hint when present, and a summary line. +func renderDoctorHuman(w io.Writer, workspace string, checks []checkResult, allOK, color bool) { + const ( + green = "\033[32m" + yellow = "\033[33m" + red = "\033[31m" + gray = "\033[90m" + bold = "\033[1m" + reset = "\033[0m" + ) + colorOf := map[string]string{"pass": green, "warn": yellow, "fail": red, "skip": gray} + tagOf := map[string]string{"pass": "PASS", "warn": "WARN", "fail": "FAIL", "skip": "SKIP"} + paint := func(code, s string) string { + if !color || code == "" { + return s + } + return code + s + reset + } + + nameW := 0 + for _, c := range checks { + if len(c.Name) > nameW { + nameW = len(c.Name) + } + } + + fmt.Fprintf(w, "\n%s (workspace: %s)\n\n", paint(bold, "lark-cli doctor"), workspace) + + var passN, warnN, failN, skipN int + for _, c := range checks { + tag := tagOf[c.Status] + if tag == "" { + tag = "????" + } + fmt.Fprintf(w, " %s %-*s %s\n", paint(colorOf[c.Status], "["+tag+"]"), nameW, c.Name, c.Message) + if c.Hint != "" { + fmt.Fprintf(w, " %-*s %s\n", nameW, "", paint(gray, "↳ "+c.Hint)) + } + switch c.Status { + case "pass": + passN++ + case "warn": + warnN++ + case "fail": + failN++ + case "skip": + skipN++ + } + } + + headline := paint(green, "healthy") + if !allOK { + headline = paint(red, "problems found") + } + fmt.Fprintf(w, "\n %s — %d passed", headline, passN) + if warnN > 0 { + fmt.Fprintf(w, ", %d warning(s)", warnN) + } + if failN > 0 { + fmt.Fprintf(w, ", %d failed", failN) + } + if skipN > 0 { + fmt.Fprintf(w, ", %d skipped", skipN) + } + fmt.Fprintln(w) +} diff --git a/cmd/doctor/doctor_test.go b/cmd/doctor/doctor_test.go index 76cfbd77e4..154495dc7d 100644 --- a/cmd/doctor/doctor_test.go +++ b/cmd/doctor/doctor_test.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "strings" "testing" @@ -17,6 +18,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/keysigner" ) func TestNewCmdDoctor_FlagParsing(t *testing.T) { @@ -144,6 +146,107 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) { assertCheck(t, got.Checks, "identity_ready", "pass") } +func TestTeeCheckResult(t *testing.T) { + avail := keysigner.HardwareInfo{Backend: "tpm2", Available: true, VendorName: "ACME"} + unavail := keysigner.HardwareInfo{Backend: "tpm2", Reason: "open /dev/tpmrm0: permission denied"} + + cases := []struct { + name string + info keysigner.HardwareInfo + ok bool + probeErr error + pkjwt bool + want string + }{ + {"no signer + private_key_jwt → fail", keysigner.HardwareInfo{}, false, nil, true, "fail"}, + {"no signer + client_secret → skip", keysigner.HardwareInfo{}, false, nil, false, "skip"}, + {"available + private_key_jwt → pass", avail, true, nil, true, "pass"}, + {"available + client_secret → pass", avail, true, nil, false, "pass"}, + {"unavailable + private_key_jwt → fail", unavail, true, nil, true, "fail"}, + {"unavailable + client_secret → warn", unavail, true, nil, false, "warn"}, + {"probe error → warn", keysigner.HardwareInfo{Backend: "tpm2"}, true, errors.New("boom"), true, "warn"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := teeCheckResult(tc.info, tc.ok, tc.probeErr, tc.pkjwt) + if got.Name != "tee_signer" { + t.Errorf("name = %q, want tee_signer", got.Name) + } + if got.Status != tc.want { + t.Errorf("status = %q, want %q (msg=%q)", got.Status, tc.want, got.Message) + } + }) + } +} + +// TestDoctorRun_TeeSignerWired proves the tee_signer check is part of doctorRun. +// It asserts the build-independent invariant (a client_secret app must never +// FAIL on TEE) so the test passes whether or not a signer is compiled in. +func TestDoctorRun_TeeSignerWired(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := core.SaveMultiAppConfig(&core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{{ + Name: "default", AppId: "test-app", + AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu, + }}, + }); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu, + }) + if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err != nil { + t.Fatalf("doctorRun() error = %v", err) + } + var got struct { + Checks []checkResult `json:"checks"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + var c *checkResult + for i := range got.Checks { + if got.Checks[i].Name == "tee_signer" { + c = &got.Checks[i] + } + } + if c == nil { + t.Fatalf("tee_signer check not present in doctor output: %#v", got.Checks) + } + if c.Status == "fail" { + t.Errorf("tee_signer = fail for a client_secret app; want skip/warn/pass (msg=%q)", c.Message) + } +} + +func TestRenderDoctorHuman(t *testing.T) { + var buf bytes.Buffer + checks := []checkResult{ + pass("cli_version", "1.0.50"), + warn("tee_signer", "tpm2 signer present but TEE unavailable", "add your user to the 'tss' group"), + fail("identity_ready", "no usable identity", "run: lark-cli auth status --verify"), + skip("endpoint_open", "skipped (--offline)"), + } + renderDoctorHuman(&buf, "local", checks, false, false) + out := buf.String() + + for _, want := range []string{ + "lark-cli doctor", "workspace: local", + "[PASS]", "cli_version", "1.0.50", + "[WARN]", "tee_signer", "↳ add your user to the 'tss' group", + "[FAIL]", "identity_ready", "↳ run: lark-cli auth status --verify", + "[SKIP]", "endpoint_open", + "problems found", "1 passed", "1 warning(s)", "1 failed", "1 skipped", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q\n---\n%s", want, out) + } + } + if strings.Contains(out, "\033[") { + t.Errorf("color=false but ANSI escapes present:\n%s", out) + } +} + func assertCheck(t *testing.T, checks []checkResult, name, status string) { t.Helper() if got := findCheck(t, checks, name); got.Status != status { diff --git a/go.mod b/go.mod index 26497138b2..d033945761 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ require ( github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 + github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c + github.com/facebookincubator/sks v0.0.0-20251112220143-6823f23937b4 github.com/gofrs/flock v0.8.1 github.com/google/uuid v1.6.0 github.com/itchyny/gojq v0.12.17 @@ -27,7 +29,10 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +require github.com/ebitengine/purego v0.10.1 + require ( + github.com/StackExchange/wmi v1.2.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect @@ -42,12 +47,21 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-ole/go-ole v1.2.5 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/btree v1.1.2 // indirect + github.com/google/certificate-transparency-go v1.1.8 // indirect + github.com/google/certtostore v1.0.6 // indirect + github.com/google/deck v0.0.0-20230104221208-105ad94aa8ae // indirect + github.com/google/go-attestation v0.5.1 // indirect + github.com/google/go-tpm v0.9.0 // indirect + github.com/google/go-tspi v0.3.0 // indirect github.com/gopherjs/gopherjs v1.17.2 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/timefmt-go v0.1.6 // indirect + github.com/jgoguen/go-utils v0.0.0-20200211015258-b42ad41486fd // indirect github.com/jtolds/gls v4.20.0+incompatible // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -57,10 +71,12 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/smarty/assertions v1.15.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/crypto v0.31.0 // indirect ) diff --git a/go.sum b/go.sum index 7e42f36199..80fedf8ddc 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= +github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -50,14 +52,42 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c h1:KqlxcP2nuOcMjudCvK0qME2K/aFBDH+xcvYv7HYQaYc= +github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c/go.mod h1:QGzNH9ujQ2ZUr/CjDGZGWeDAVStrWNjHeEcjJL96Nuk= +github.com/facebookincubator/sks v0.0.0-20251112220143-6823f23937b4 h1:z9oNXvtDZv73Rg8UjFhu+wMtDvGkhLm1NMTwZQ68gOM= +github.com/facebookincubator/sks v0.0.0-20251112220143-6823f23937b4/go.mod h1:FEWpPBUpkMwxqAbprURvgWgdwjeGkge5QFDaZBsfRHQ= +github.com/go-ole/go-ole v1.2.5 h1:t4MGB5xEDZvXI+0rMjjsfBsD7yAgp/s9ZDkL1JndXwY= +github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= +github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= +github.com/google/certificate-transparency-go v1.1.8 h1:LGYKkgZF7satzgTak9R4yzfJXEeYVAjV6/EAEJOf1to= +github.com/google/certificate-transparency-go v1.1.8/go.mod h1:bV/o8r0TBKRf1X//iiiSgWrvII4d7/8OiA+3vG26gI8= +github.com/google/certtostore v1.0.6 h1:LlCIgyTvDxTlcncMPTSYZGo6lCsiHzO6Dy7ff6ltk/0= +github.com/google/certtostore v1.0.6/go.mod h1:2N0ZPLkGvQWhYvXaiBGq02r71fnSLfq78VKIWQHr1wo= +github.com/google/deck v0.0.0-20230104221208-105ad94aa8ae h1:Iy1Ad7L9qPtNAFJad+Ch2kwDXrcwu7QUBR0bfChjnEM= +github.com/google/deck v0.0.0-20230104221208-105ad94aa8ae/go.mod h1:DoDv8G58DuLNZF0KysYn0bA/6ZWhmRW3fZE2VnGEH0w= +github.com/google/go-attestation v0.5.1 h1:jqtOrLk5MNdliTKjPbIPrAaRKJaKW+0LIU2n/brJYms= +github.com/google/go-attestation v0.5.1/go.mod h1:KqGatdUhg5kPFkokyzSBDxwSCFyRgIgtRkMp6c3lOBQ= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk= +github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU= +github.com/google/go-tpm-tools v0.4.2 h1:iyaCPKt2N5Rd0yz0G8ANa022SgCNZkMpp+db6QELtvI= +github.com/google/go-tpm-tools v0.4.2/go.mod h1:fGUDZu4tw3V4hUVuFHmiYgRd0c58/IXivn9v3Ea/ck4= +github.com/google/go-tspi v0.3.0 h1:ADtq8RKfP+jrTyIWIZDIYcKOMecRqNJFOew2IT0Inus= +github.com/google/go-tspi v0.3.0/go.mod h1:xfMGI3G0PhxCdNVcYr1C4C+EizojDg/TXuX5by8CiHI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= @@ -70,6 +100,8 @@ github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY= github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= +github.com/jgoguen/go-utils v0.0.0-20200211015258-b42ad41486fd h1:E3y4CkzAXArgOQAw9gzW0Exe7XQqF4MYH3rCYprAj+Q= +github.com/jgoguen/go-utils v0.0.0-20200211015258-b42ad41486fd/go.mod h1:ayRB9iNq3dqzUb9oW2JkoVQkDBkJ88NJb66OH13CKSk= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -97,6 +129,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -137,6 +171,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -154,6 +190,7 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/auth/app_registration.go b/internal/auth/app_registration.go index 44d5c3af95..329d7dbd5f 100644 --- a/internal/auth/app_registration.go +++ b/internal/auth/app_registration.go @@ -65,6 +65,7 @@ type AppRegistrationResponse struct { VerificationUriComplete string ExpiresIn int Interval int + RequestedAuthMethod string } // AppRegistrationResult is the result of a successful app registration poll. @@ -72,6 +73,11 @@ type AppRegistrationResult struct { ClientID string ClientSecret string UserInfo *AppRegUserInfo + // AuthMethods is the authoritative auth method(s) the app must use, as + // returned by the registration service after user/admin confirmation. It may + // differ from what the client requested, for example when selecting an + // existing client_secret app. Empty is accepted for compatible older servers. + AuthMethods []string } // AppRegUserInfo contains user info returned from app registration. @@ -85,10 +91,81 @@ func appRegistrationEndpoint(brand core.LarkBrand) string { return core.ResolveEndpoints(brand).Accounts + PathAppRegistration } +// AppRegistrationInit is the response from the app registration init endpoint. +type AppRegistrationInit struct { + Nonce string + SupportedAuthMethods []string // e.g. ["client_secret", "private_key_jwt"] +} + +// AppRegistrationBeginOptions parametrizes the registration begin request. +// A zero value selects the legacy client_secret flow, preserving prior behavior. +type AppRegistrationBeginOptions struct { + AuthMethod string // "" => client_secret; core.AuthMethodPrivateKeyJWT + AuthAttestation string // private_key_jwt: the TEE-signed attestation JWT + RestoreAppID string // when set, asks the server to re-register this existing app +} + +// RequestAppRegistrationInit performs the init step of the registration flow, +// returning a server nonce (to be embedded in a TEE-signed attestation JWT) and +// the auth methods the server supports for this archetype. +func RequestAppRegistrationInit(ctx context.Context, httpClient *http.Client) (*AppRegistrationInit, error) { + // Registration always begins against the Feishu accounts host (mirrors begin). + endpoint := appRegistrationEndpoint(registrationBootstrapBrand) + ctx, cancel := context.WithTimeout(ctx, beginRequestTimeout) + defer cancel() + + form := url.Values{} + form.Set("action", "init") + form.Set("archetype", "PersonalAgent") + + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + logHTTPResponse(resp) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("app registration init failed: read body: %w", err) + } + + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + return nil, fmt.Errorf("app registration init failed: HTTP %d – response not JSON", resp.StatusCode) + } + + if _, hasError := data["error"]; resp.StatusCode >= 400 || hasError { + msg := getStr(data, "error_description") + if msg == "" { + msg = getStr(data, "error") + } + if msg == "" { + msg = "Unknown error" + } + return nil, fmt.Errorf("app registration init failed: %s", msg) + } + + out := &AppRegistrationInit{ + Nonce: getStr(data, "nonce"), + SupportedAuthMethods: parseAuthMethods(data["supported_auth_methods"]), + } + if out.Nonce == "" { + return nil, fmt.Errorf("app registration init failed: server returned no nonce") + } + return out, nil +} + // RequestAppRegistration initiates the device flow. The registration protocol // always bootstraps on Feishu; brand selects the user-facing verification host. // The request is bounded by ctx and a begin timeout. -func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) { +func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, opts AppRegistrationBeginOptions, errOut io.Writer) (*AppRegistrationResponse, error) { if errOut == nil { errOut = io.Discard } @@ -99,11 +176,25 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand ep := core.ResolveEndpoints(brand) endpoint := appRegistrationEndpoint(registrationBootstrapBrand) + authMethod := opts.AuthMethod + if authMethod == "" { + authMethod = core.AuthMethodClientSecret + } + form := url.Values{} form.Set("action", "begin") form.Set("archetype", "PersonalAgent") - form.Set("auth_method", "client_secret") + form.Set("auth_method", authMethod) form.Set("request_user_info", "open_id tenant_brand") + if opts.AuthAttestation != "" { + form.Set("auth_attestation", opts.AuthAttestation) + } + // Restore flow: the registration service accepts the existing OAuth client + // identifier under client_id. The launcher URL still uses app_id; these are + // separate contracts and must not be changed together. + if opts.RestoreAppID != "" { + form.Set("client_id", opts.RestoreAppID) + } req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(form.Encode())) if err != nil { @@ -156,7 +247,24 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand userCode := getStr(data, "user_code") verificationUri := getStr(data, "verification_uri") - verificationUriComplete := fmt.Sprintf("%s/page/cli?user_code=%s", ep.Open, userCode) + // Prefer the server-provided complete URL (currently /page/launcher); fall + // back to building it from verification_uri, then to /page/launcher. The old + // hard-coded /page/cli is stale — the server now returns /page/launcher. + verificationUriComplete := getStr(data, "verification_uri_complete") + if verificationUriComplete == "" { + base := verificationUri + if base == "" { + base = ep.Open + "/page/launcher" + } + // The server may return verification_uri with its own query (e.g. + // app_id when registering against an existing app), so join with + // the same ?/& logic as BuildVerificationURL. + sep := "?" + if strings.Contains(base, "?") { + sep = "&" + } + verificationUriComplete = base + sep + "user_code=" + url.QueryEscape(userCode) + } return &AppRegistrationResponse{ DeviceCode: deviceCode, @@ -165,18 +273,91 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand VerificationUriComplete: verificationUriComplete, ExpiresIn: expiresIn, Interval: interval, + RequestedAuthMethod: authMethod, }, nil } +// parseAuthMethods normalizes the poll response `auth_method` field, which the +// server returns as a JSON array of strings (e.g. ["private_key_jwt"]) — or, on +// some variants, a single space-separated string. +func parseAuthMethods(v interface{}) []string { + switch t := v.(type) { + case []interface{}: + out := make([]string, 0, len(t)) + for _, m := range t { + if s, ok := m.(string); ok && s != "" { + out = append(out, s) + } + } + return out + case string: + return strings.Fields(t) + default: + return nil + } +} + +func containsAuthMethod(methods []string, target string) bool { + for _, method := range methods { + if method == target { + return true + } + } + return false +} + +func registrationResultComplete(result *AppRegistrationResult, requestedAuthMethod string) bool { + if result.ClientID == "" { + return false + } + if result.ClientSecret != "" { + return true + } + if len(result.AuthMethods) > 0 { + return containsAuthMethod(result.AuthMethods, core.AuthMethodPrivateKeyJWT) + } + // Older servers may omit auth_method. In that case only a begin request + // explicitly made as private_key_jwt may complete without a client secret. + return requestedAuthMethod == core.AuthMethodPrivateKeyJWT +} + // BuildVerificationURL appends CLI tracking parameters to the verification URL. -func BuildVerificationURL(baseURL, cliVersion string) string { +// When targetAppID is non-empty, it is also included so the launcher can lock +// authorization to that existing app. +func BuildVerificationURL(baseURL, cliVersion string, targetAppID ...string) string { + u, err := url.Parse(baseURL) + if err != nil { + return appendVerificationURLFallback(baseURL, cliVersion, targetAppID...) + } + q := u.Query() + if q.Get("lpv") == "" { + q.Set("lpv", cliVersion) + } + if q.Get("ocv") == "" { + q.Set("ocv", cliVersion) + } + if q.Get("from") == "" { + q.Set("from", "cli") + } + if len(targetAppID) > 0 && targetAppID[0] != "" && q.Get("app_id") == "" { + q.Set("app_id", targetAppID[0]) + } + u.RawQuery = q.Encode() + return u.String() +} + +func appendVerificationURLFallback(baseURL, cliVersion string, targetAppID ...string) string { sep := "&" if !strings.Contains(baseURL, "?") { sep = "?" } - return baseURL + sep + "lpv=" + url.QueryEscape(cliVersion) + + out := baseURL + sep + "lpv=" + url.QueryEscape(cliVersion) + "&ocv=" + url.QueryEscape(cliVersion) + "&from=cli" + if len(targetAppID) > 0 && targetAppID[0] != "" && !strings.Contains(baseURL, "app_id=") { + out += "&app_id=" + url.QueryEscape(targetAppID[0]) + } + return out } // pollOnce performs one ctx-bound poll request and decodes the payload. @@ -273,6 +454,7 @@ func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp result := &AppRegistrationResult{ ClientID: getStr(data, "client_id"), ClientSecret: getStr(data, "client_secret"), + AuthMethods: parseAuthMethods(data["auth_method"]), } if userInfoRaw, ok := data["user_info"].(map[string]interface{}); ok { result.UserInfo = &AppRegUserInfo{ @@ -281,7 +463,7 @@ func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp } } - if result.ClientID != "" && result.ClientSecret != "" { + if registrationResultComplete(result, resp.RequestedAuthMethod) { // The issuing domain is authoritative; a contradictory final // tenant report is a protocol violation, not a brand override. if result.UserInfo != nil && result.UserInfo.TenantBrand != "" && diff --git a/internal/auth/app_registration_test.go b/internal/auth/app_registration_test.go index 64993e9ccb..05271ead36 100644 --- a/internal/auth/app_registration_test.go +++ b/internal/auth/app_registration_test.go @@ -8,6 +8,8 @@ import ( "errors" "io" "net/http" + "net/url" + "slices" "strings" "testing" "time" @@ -30,10 +32,14 @@ func jsonResponse(body string) *http.Response { func Test_BuildVerificationURL(t *testing.T) { t.Run("URL不含问号则添加?分隔符", func(t *testing.T) { result := BuildVerificationURL("https://example.com/verify", "1.0.0") + got, err := url.Parse(result) + if err != nil { + t.Fatal(err) + } convey.Convey("should add ? separator", t, func() { - convey.So(result, convey.ShouldContainSubstring, "?lpv=1.0.0") - convey.So(result, convey.ShouldContainSubstring, "&ocv=1.0.0") - convey.So(result, convey.ShouldContainSubstring, "&from=cli") + convey.So(got.Query().Get("lpv"), convey.ShouldEqual, "1.0.0") + convey.So(got.Query().Get("ocv"), convey.ShouldEqual, "1.0.0") + convey.So(got.Query().Get("from"), convey.ShouldEqual, "cli") convey.So(result, convey.ShouldStartWith, "https://example.com/verify?") }) }) @@ -47,6 +53,237 @@ func Test_BuildVerificationURL(t *testing.T) { convey.So(result, convey.ShouldNotContainSubstring, "?lpv=") }) }) + + t.Run("指定已有应用时添加app_id", func(t *testing.T) { + result := BuildVerificationURL("https://example.com/verify?user_code=abc", "2.0.0", "cli_existing") + got, err := url.Parse(result) + if err != nil { + t.Fatal(err) + } + convey.Convey("should include target app_id", t, func() { + convey.So(got.Query().Get("app_id"), convey.ShouldEqual, "cli_existing") + convey.So(got.Query().Get("client_id"), convey.ShouldEqual, "") + convey.So(got.Query().Get("lpv"), convey.ShouldEqual, "2.0.0") + }) + }) + + t.Run("服务端已返回app_id时不覆盖", func(t *testing.T) { + result := BuildVerificationURL("https://example.com/verify?app_id=cli_server&user_code=abc", "2.0.0", "cli_existing") + got, err := url.Parse(result) + if err != nil { + t.Fatal(err) + } + convey.Convey("should keep server app_id", t, func() { + convey.So(got.Query().Get("app_id"), convey.ShouldEqual, "cli_server") + }) + }) +} + +// captureClient returns an http.Client that records the last request's form body +// and replies with the given JSON payload. +func captureClient(gotBody *url.Values, respJSON string) *http.Client { + return &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Body != nil { + b, _ := io.ReadAll(req.Body) + v, _ := url.ParseQuery(string(b)) + *gotBody = v + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(respJSON)), + }, nil + }), + } +} + +func TestRequestAppRegistrationInit_ParsesNonceAndMethods(t *testing.T) { + var body url.Values + hc := captureClient(&body, `{"nonce":"n-123","supported_auth_methods":["client_secret","private_key_jwt"]}`) + + out, err := RequestAppRegistrationInit(context.Background(), hc) + if err != nil { + t.Fatal(err) + } + if out.Nonce != "n-123" { + t.Errorf("nonce = %q, want n-123", out.Nonce) + } + if len(out.SupportedAuthMethods) != 2 || out.SupportedAuthMethods[1] != "private_key_jwt" { + t.Errorf("methods = %v", out.SupportedAuthMethods) + } + if body.Get("action") != "init" { + t.Errorf("action = %q, want init", body.Get("action")) + } +} + +func TestRequestAppRegistrationInit_ErrorOnMissingNonce(t *testing.T) { + var body url.Values + hc := captureClient(&body, `{"supported_auth_methods":["client_secret"]}`) + if _, err := RequestAppRegistrationInit(context.Background(), hc); err == nil { + t.Fatal("expected error when server returns no nonce") + } +} + +// TestRequestAppRegistrationInit_EmptySupportedAuthMethods covers the older-server +// back-compat path: an empty supported_auth_methods array parses to an empty +// slice, so the init guard in cmd/config/init_interactive.go +// (`len(SupportedAuthMethods) > 0 && !slices.Contains(...)`) stays false and does +// NOT reject the requested private_key_jwt. This aligns with +// resolveFinalAuthMethod(nil/[], private_key_jwt) == private_key_jwt +// (see cmd/config TestResolveFinalAuthMethod). +func TestRequestAppRegistrationInit_EmptySupportedAuthMethods(t *testing.T) { + var body url.Values + hc := captureClient(&body, `{"nonce":"n-1","supported_auth_methods":[]}`) + + out, err := RequestAppRegistrationInit(context.Background(), hc) + if err != nil { + t.Fatal(err) + } + if out.Nonce != "n-1" { + t.Errorf("nonce = %q, want n-1", out.Nonce) + } + if len(out.SupportedAuthMethods) != 0 { + t.Errorf("SupportedAuthMethods = %v, want empty", out.SupportedAuthMethods) + } + // Reproduce the init guard expression on the real parsed result: an empty + // slice must NOT reject private_key_jwt. + rejected := len(out.SupportedAuthMethods) > 0 && + !slices.Contains(out.SupportedAuthMethods, core.AuthMethodPrivateKeyJWT) + if rejected { + t.Error("empty SupportedAuthMethods must allow private_key_jwt (older-server back-compat)") + } +} + +const beginRespJSON = `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}` + +func TestRequestAppRegistration_BeginDefaultsToClientSecret(t *testing.T) { + var body url.Values + hc := captureClient(&body, beginRespJSON) + + if _, err := RequestAppRegistration(context.Background(), hc, core.BrandFeishu, AppRegistrationBeginOptions{}, nil); err != nil { + t.Fatal(err) + } + if body.Get("action") != "begin" { + t.Errorf("action = %q", body.Get("action")) + } + if body.Get("auth_method") != "client_secret" { + t.Errorf("auth_method = %q, want client_secret (default)", body.Get("auth_method")) + } + if body.Has("auth_attestation") { + t.Errorf("auth_attestation should be absent for client_secret, got %q", body.Get("auth_attestation")) + } + // Normal (non-restore) begin must NOT carry client_id. + if body.Has("client_id") { + t.Errorf("client_id should be absent when RestoreAppID is empty, got %q", body.Get("client_id")) + } +} + +// TestRequestAppRegistration_BeginRestoreAppID verifies the restore flow sends the +// existing app id on begin so the server re-registers that app. +func TestRequestAppRegistration_BeginRestoreAppID(t *testing.T) { + var body url.Values + hc := captureClient(&body, beginRespJSON) + + opts := AppRegistrationBeginOptions{RestoreAppID: "cli_restore_me"} + if _, err := RequestAppRegistration(context.Background(), hc, core.BrandFeishu, opts, nil); err != nil { + t.Fatal(err) + } + if body.Get("action") != "begin" { + t.Errorf("action = %q, want begin", body.Get("action")) + } + if body.Get("client_id") != "cli_restore_me" { + t.Errorf("client_id = %q, want cli_restore_me", body.Get("client_id")) + } + if body.Has("app_id") { + t.Errorf("begin form app_id must be absent, got %q", body.Get("app_id")) + } +} + +func TestRequestAppRegistration_VerificationURICompleteFallback(t *testing.T) { + cases := []struct { + name string + resp string + want string + }{ + { + name: "bare verification_uri", + resp: `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}`, + want: "https://example/verify?user_code=uc", + }, + { + name: "verification_uri with existing query", + resp: `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify?app_id=cli_x","expires_in":300,"interval":5}`, + want: "https://example/verify?app_id=cli_x&user_code=uc", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var body url.Values + hc := captureClient(&body, tc.resp) + got, err := RequestAppRegistration(context.Background(), hc, core.BrandFeishu, AppRegistrationBeginOptions{}, nil) + if err != nil { + t.Fatal(err) + } + if got.VerificationUriComplete != tc.want { + t.Errorf("VerificationUriComplete = %q, want %q", got.VerificationUriComplete, tc.want) + } + }) + } +} + +func TestParseAuthMethods(t *testing.T) { + if got := parseAuthMethods([]interface{}{"private_key_jwt", "client_secret"}); len(got) != 2 || got[0] != "private_key_jwt" { + t.Errorf("array form = %v", got) + } + if got := parseAuthMethods("client_secret private_key_jwt"); len(got) != 2 || got[1] != "private_key_jwt" { + t.Errorf("string form = %v", got) + } + if got := parseAuthMethods(nil); got != nil { + t.Errorf("nil form = %v, want nil", got) + } +} + +func TestRequestAppRegistration_BeginPrivateKeyJWT(t *testing.T) { + var body url.Values + hc := captureClient(&body, beginRespJSON) + + opts := AppRegistrationBeginOptions{ + AuthMethod: core.AuthMethodPrivateKeyJWT, + AuthAttestation: "header.claims.sig", + } + if _, err := RequestAppRegistration(context.Background(), hc, core.BrandFeishu, opts, nil); err != nil { + t.Fatal(err) + } + if body.Get("auth_method") != "private_key_jwt" { + t.Errorf("auth_method = %q, want private_key_jwt", body.Get("auth_method")) + } + if body.Get("auth_attestation") != "header.claims.sig" { + t.Errorf("auth_attestation = %q", body.Get("auth_attestation")) + } +} + +func TestRequestAppRegistration_BeginPrivateKeyJWTExistingAppID(t *testing.T) { + var body url.Values + hc := captureClient(&body, beginRespJSON) + + opts := AppRegistrationBeginOptions{ + AuthMethod: core.AuthMethodPrivateKeyJWT, + AuthAttestation: "header.claims.sig", + RestoreAppID: "cli_existing", + } + if _, err := RequestAppRegistration(context.Background(), hc, core.BrandFeishu, opts, nil); err != nil { + t.Fatal(err) + } + if body.Get("auth_method") != "private_key_jwt" { + t.Errorf("auth_method = %q, want private_key_jwt", body.Get("auth_method")) + } + if body.Get("auth_attestation") != "header.claims.sig" { + t.Errorf("auth_attestation = %q", body.Get("auth_attestation")) + } + if body.Get("client_id") != "cli_existing" { + t.Errorf("client_id = %q, want cli_existing", body.Get("client_id")) + } } func TestAppRegistrationEndpoint(t *testing.T) { @@ -80,11 +317,11 @@ func TestRequestAppRegistration_UsesFeishuBootstrapAndConfiguredVerificationBran } return jsonResponse(`{"device_code":"d","user_code":"TEST-CODE","expire_in":60,"interval":5}`), nil })} - resp, err := RequestAppRegistration(context.Background(), client, c.brand, io.Discard) + resp, err := RequestAppRegistration(context.Background(), client, c.brand, AppRegistrationBeginOptions{}, io.Discard) if err != nil { t.Fatalf("RequestAppRegistration(%q) error = %v", c.brand, err) } - if !strings.HasPrefix(resp.VerificationUriComplete, "https://"+c.verificationHost+"/page/cli?") { + if !strings.HasPrefix(resp.VerificationUriComplete, "https://"+c.verificationHost+"/page/launcher?") { t.Errorf("verification URL = %q, want host %q", resp.VerificationUriComplete, c.verificationHost) } }) @@ -115,11 +352,11 @@ func TestRegisterAppWithDiscovery_LarkFlowUsesProtocolBootstrap(t *testing.T) { t.Errorf("unexpected host polled: %s", r.URL.Host) return jsonResponse(`{}`), nil })} - resp, err := RequestAppRegistration(context.Background(), client, core.BrandLark, io.Discard) + resp, err := RequestAppRegistration(context.Background(), client, core.BrandLark, AppRegistrationBeginOptions{}, io.Discard) if err != nil { t.Fatalf("RequestAppRegistration error = %v", err) } - if got, want := resp.VerificationUriComplete, "https://open.larksuite.com/page/cli?user_code=TEST-CODE"; got != want { + if got, want := resp.VerificationUriComplete, "https://open.larksuite.com/page/launcher?user_code=TEST-CODE"; got != want { t.Errorf("verification URL = %q, want %q", got, want) } @@ -219,6 +456,51 @@ func TestRegisterAppWithDiscovery_PollsUntilCredentials(t *testing.T) { } } +func TestRegisterAppWithDiscovery_KeylessCompletesWithoutSecret(t *testing.T) { + tests := []struct { + name string + response string + requestedAuthMethod string + }{ + { + name: "server explicitly returns private_key_jwt", + response: `{"client_id":"cli_keyless","auth_method":["private_key_jwt"]}`, + }, + { + name: "older server omits auth_method for a keyless begin", + response: `{"client_id":"cli_keyless"}`, + requestedAuthMethod: core.AuthMethodPrivateKeyJWT, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + polls := 0 + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + polls++ + return jsonResponse(tt.response), nil + })} + resp := &AppRegistrationResponse{ + DeviceCode: "device", + Interval: 0, + ExpiresIn: 5, + RequestedAuthMethod: tt.requestedAuthMethod, + } + + result, _, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard) + if err != nil { + t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err) + } + if polls != 1 { + t.Errorf("polls = %d, want 1", polls) + } + if result.ClientID != "cli_keyless" || result.ClientSecret != "" { + t.Errorf("result = (%q, %q), want (cli_keyless, empty secret)", result.ClientID, result.ClientSecret) + } + }) + } +} + // Neither the first poll nor the cross-brand switch waits out the interval // (a 5s interval would blow the elapsed bound). func TestRegisterAppWithDiscovery_ImmediateFirstPollAndSwitch(t *testing.T) { @@ -286,7 +568,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) { } resp, err := RequestAppRegistration(context.Background(), - serve(`{"device_code":"d","expire_in":60,"interval":3}`), core.BrandFeishu, io.Discard) + serve(`{"device_code":"d","expire_in":60,"interval":3}`), core.BrandFeishu, AppRegistrationBeginOptions{}, io.Discard) if err != nil { t.Fatalf("begin error = %v", err) } @@ -295,7 +577,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) { } resp, err = RequestAppRegistration(context.Background(), - serve(`{"device_code":"d","expires_in":45}`), core.BrandFeishu, io.Discard) + serve(`{"device_code":"d","expires_in":45}`), core.BrandFeishu, AppRegistrationBeginOptions{}, io.Discard) if err != nil { t.Fatalf("legacy begin error = %v", err) } @@ -304,7 +586,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) { } resp, err = RequestAppRegistration(context.Background(), - serve(`{"device_code":"d","interval":0}`), core.BrandFeishu, io.Discard) + serve(`{"device_code":"d","interval":0}`), core.BrandFeishu, AppRegistrationBeginOptions{}, io.Discard) if err != nil { t.Fatalf("defaults begin error = %v", err) } @@ -313,7 +595,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) { } if _, err := RequestAppRegistration(context.Background(), - serve(`{"interval":5}`), core.BrandFeishu, io.Discard); err == nil { + serve(`{"interval":5}`), core.BrandFeishu, AppRegistrationBeginOptions{}, io.Discard); err == nil { t.Error("missing device_code: expected error, got nil") } } @@ -394,7 +676,7 @@ func TestRequestAppRegistration_BodyReadCancelKeepsCause(t *testing.T) { Header: make(http.Header), }, nil })} - _, err := RequestAppRegistration(context.Background(), client, core.BrandFeishu, io.Discard) + _, err := RequestAppRegistration(context.Background(), client, core.BrandFeishu, AppRegistrationBeginOptions{}, io.Discard) if !errors.Is(err, context.Canceled) { t.Errorf("err = %v, want a context.Canceled cause", err) } diff --git a/internal/auth/client_auth.go b/internal/auth/client_auth.go new file mode 100644 index 0000000000..c6ede06e8a --- /dev/null +++ b/internal/auth/client_auth.go @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "fmt" + "net/url" + "time" + + "github.com/larksuite/cli/internal/auth/jwt" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/keylesshelper" + "github.com/larksuite/cli/internal/keylessprovider" + "github.com/larksuite/cli/internal/keysigner" +) + +// ClientAuth describes how to authenticate the OAuth client at the token +// endpoint: with a client_secret (default) or a TEE-signed client_assertion +// (private_key_jwt). +type ClientAuth struct { + AppID string + AppSecret string + AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT + Signer keysigner.Signer + KeyLabel string + KeyProvider string + + // externalSigner is a verified provider snapshot prepared once for a + // multi-request operation (for example a device-flow poll loop). The helper + // still re-verifies its binary and mints a fresh assertion on every call. + externalSigner clientAssertionSigner +} + +type clientAssertionSigner interface { + SignClientAssertion(context.Context, string, string, string) (string, string, error) +} + +var resolveExternalAssertionSigner = func(ctx context.Context, provider string) (clientAssertionSigner, error) { + return keylessprovider.Resolve(ctx, provider) +} + +// ClientAuthFromConfig builds a ClientAuth from resolved config, picking up the +// active key signer for private_key_jwt apps. +func ClientAuthFromConfig(cfg *core.CliConfig) ClientAuth { + if cfg == nil { + return ClientAuth{} + } + return ClientAuth{ + AppID: cfg.AppID, + AppSecret: cfg.AppSecret, + AuthMethod: cfg.AuthMethod, + KeyLabel: cfg.KeyLabel, + KeyProvider: cfg.KeyProvider, + Signer: keysigner.Active(), + } +} + +func (c ClientAuth) isPrivateKeyJWT() bool { return c.AuthMethod == core.AuthMethodPrivateKeyJWT } + +// ResolveSigner prepares the external private_key_jwt signer for reuse within +// one operation and returns the prepared copy. Built-in signers and +// client_secret authentication need no provider discovery. Keeping the +// resolved helper on ClientAuth separates expensive provider discovery from +// assertion minting: callers may reuse the returned value, while every call to +// applyClientAssertion still asks the signer for a fresh assertion. +func (c ClientAuth) ResolveSigner(ctx context.Context) (ClientAuth, error) { + if !c.isPrivateKeyJWT() || c.KeyProvider == "" || c.externalSigner != nil { + return c, nil + } + helper, err := resolveExternalAssertionSigner(ctx, c.KeyProvider) + if err != nil { + return c, err + } + if helper == nil { + return c, fmt.Errorf("private_key_jwt provider %q resolved without a signer", c.KeyProvider) + } + c.externalSigner = helper + return c, nil +} + +// SignClientAssertion signs with a resolved external helper when present, +// otherwise with the platform signer. +func SignClientAssertion(ctx context.Context, signer keysigner.Signer, helper *keylesshelper.Command, keyLabel, clientID, audience string) (string, string, error) { + if helper != nil { + return helper.SignClientAssertion(ctx, keyLabel, clientID, audience) + } + assertion, err := jwt.SignClientAssertion(ctx, signer, keysigner.KeyRef{Label: keyLabel}, clientID, audience, time.Now()) + return jwt.ClientAssertionType, assertion, err +} + +// applyClientAssertion adds client_assertion(+type) to a token-endpoint form for +// private_key_jwt and returns true. For client_secret it returns false, leaving +// the caller to apply its own secret-based authentication. audience is the token +// endpoint URL (the assertion's aud claim). +func (c ClientAuth) applyClientAssertion(ctx context.Context, form url.Values, audience string) (bool, error) { + if !c.isPrivateKeyJWT() { + return false, nil + } + var err error + if c.KeyProvider != "" { + c, err = c.ResolveSigner(ctx) + if err != nil { + return false, err + } + } + helper := c.externalSigner + if helper == nil && c.Signer == nil { + return false, fmt.Errorf("private_key_jwt requires a key signer, but none is available on this build") + } + var assertionType, assertion string + if helper != nil { + assertionType, assertion, err = helper.SignClientAssertion(ctx, c.KeyLabel, c.AppID, audience) + } else { + assertionType, assertion, err = SignClientAssertion(ctx, c.Signer, nil, c.KeyLabel, c.AppID, audience) + } + if err != nil { + return false, err + } + form.Set("client_assertion_type", assertionType) + form.Set("client_assertion", assertion) + return true, nil +} diff --git a/internal/auth/client_auth_test.go b/internal/auth/client_auth_test.go new file mode 100644 index 0000000000..865b8929ef --- /dev/null +++ b/internal/auth/client_auth_test.go @@ -0,0 +1,227 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "fmt" + "net/url" + "testing" + + "github.com/larksuite/cli/internal/auth/jwt" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/keysigner" +) + +// fakeAuthSigner is a real in-memory ECDSA P-256 signer for client-auth tests. +type fakeAuthSigner struct{ key *ecdsa.PrivateKey } + +type fakeExternalAssertionSigner struct { + keyRef, clientID, audience string + calls int +} + +func (f *fakeExternalAssertionSigner) SignClientAssertion(_ context.Context, keyRef, clientID, audience string) (string, string, error) { + f.keyRef, f.clientID, f.audience = keyRef, clientID, audience + f.calls++ + return jwt.ClientAssertionType, fmt.Sprintf("external.jwt.%d", f.calls), nil +} + +func newFakeAuthSigner(t *testing.T) *fakeAuthSigner { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + return &fakeAuthSigner{key: k} +} + +func (f *fakeAuthSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) { + return f.key.Public(), nil +} +func (f *fakeAuthSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) { + return f.key.Public(), nil +} +func (f *fakeAuthSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) { + h := sha256.Sum256(in) + r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:]) + if err != nil { + return nil, "", err + } + sig := make([]byte, 64) + r.FillBytes(sig[:32]) + s.FillBytes(sig[32:]) + return sig, keysigner.AlgES256, nil +} + +func TestClientAuth_applyClientAssertion_ClientSecret(t *testing.T) { + ca := ClientAuth{AppID: "cli_a", AppSecret: "test-secret"} // AuthMethod "" => client_secret + form := url.Values{} + used, err := ca.applyClientAssertion(context.Background(), form, "https://aud/token") + if err != nil { + t.Fatal(err) + } + if used { + t.Error("client_secret must not produce a client_assertion") + } + if form.Has("client_assertion") || form.Has("client_assertion_type") { + t.Errorf("form should be untouched, got %v", form) + } +} + +func TestClientAuth_applyClientAssertion_PrivateKeyJWT(t *testing.T) { + ca := ClientAuth{ + AppID: "cli_a", + AuthMethod: core.AuthMethodPrivateKeyJWT, + Signer: newFakeAuthSigner(t), + KeyLabel: "k", + } + form := url.Values{} + used, err := ca.applyClientAssertion(context.Background(), form, "https://accounts.feishu.cn/open-apis/authen/v2/oauth/token") + if err != nil { + t.Fatal(err) + } + if !used { + t.Fatal("expected client_assertion to be applied") + } + if form.Get("client_assertion_type") != jwt.ClientAssertionType { + t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type")) + } + if form.Get("client_assertion") == "" { + t.Error("client_assertion is empty") + } + if form.Has("client_secret") { + t.Error("client_secret must NOT be present for private_key_jwt") + } +} + +func TestClientAuth_applyClientAssertion_NilSigner(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + ca := ClientAuth{AppID: "cli_a", AuthMethod: core.AuthMethodPrivateKeyJWT} // Signer nil + if _, err := ca.applyClientAssertion(context.Background(), url.Values{}, "aud"); err == nil { + t.Fatal("expected error when private_key_jwt has no signer") + } +} + +func TestClientAuth_applyClientAssertion_UnknownProviderFailsClosed(t *testing.T) { + ca := ClientAuth{AppID: "cli_a", AuthMethod: core.AuthMethodPrivateKeyJWT, Signer: newFakeAuthSigner(t), KeyLabel: "k", KeyProvider: "evil.provider"} + form := url.Values{} + used, err := ca.applyClientAssertion(context.Background(), form, "aud") + if err == nil || used || form.Has("client_assertion") { + t.Fatalf("unknown provider must fail closed: used=%v form=%v err=%v", used, form, err) + } +} + +func TestClientAuth_applyClientAssertion_NilExternalProviderDoesNotFallback(t *testing.T) { + previous := resolveExternalAssertionSigner + resolveExternalAssertionSigner = func(context.Context, string) (clientAssertionSigner, error) { + return nil, nil + } + t.Cleanup(func() { resolveExternalAssertionSigner = previous }) + + ca := ClientAuth{ + AppID: "cli_a", AppSecret: "must-not-send", AuthMethod: core.AuthMethodPrivateKeyJWT, + Signer: newFakeAuthSigner(t), KeyLabel: "k", KeyProvider: core.KeylessProviderLarkSuite, + } + form := url.Values{} + used, err := ca.applyClientAssertion(context.Background(), form, "aud") + if err == nil || used || form.Has("client_assertion") || form.Has("client_secret") { + t.Fatalf("nil external provider must fail closed: used=%v form=%v err=%v", used, form, err) + } +} + +func TestClientAuth_applyClientAssertion_ExplicitProviderDoesNotUseBuiltinOrSecret(t *testing.T) { + fake := &fakeExternalAssertionSigner{} + previous := resolveExternalAssertionSigner + resolveExternalAssertionSigner = func(_ context.Context, provider string) (clientAssertionSigner, error) { + if provider != core.KeylessProviderLarkSuite { + t.Fatalf("provider = %q", provider) + } + return fake, nil + } + t.Cleanup(func() { resolveExternalAssertionSigner = previous }) + + ca := ClientAuth{ + AppID: "cli_external", AppSecret: "must-not-send", AuthMethod: core.AuthMethodPrivateKeyJWT, + Signer: newFakeAuthSigner(t), KeyLabel: "openclaw-lark", KeyProvider: core.KeylessProviderLarkSuite, + } + form := url.Values{} + used, err := ca.applyClientAssertion(context.Background(), form, "open.feishu.cn") + if err != nil || !used { + t.Fatalf("applyClientAssertion = used %v err %v", used, err) + } + if form.Get("client_assertion") != "external.jwt.1" || form.Has("client_secret") || + fake.keyRef != "openclaw-lark" || fake.clientID != "cli_external" || fake.audience != "open.feishu.cn" { + t.Fatalf("form=%v signer=(%q,%q,%q)", form, fake.keyRef, fake.clientID, fake.audience) + } +} + +func TestClientAuth_ResolveSignerPreparedCopyReusesResolutionAndRemintsAssertions(t *testing.T) { + fake := &fakeExternalAssertionSigner{} + resolveCalls := 0 + previous := resolveExternalAssertionSigner + resolveExternalAssertionSigner = func(_ context.Context, provider string) (clientAssertionSigner, error) { + resolveCalls++ + if provider != core.KeylessProviderLarkSuite { + t.Fatalf("provider = %q", provider) + } + return fake, nil + } + t.Cleanup(func() { resolveExternalAssertionSigner = previous }) + + original := ClientAuth{ + AppID: "cli_external", AuthMethod: core.AuthMethodPrivateKeyJWT, + KeyLabel: "openclaw-lark", KeyProvider: core.KeylessProviderLarkSuite, + } + prepared, err := original.ResolveSigner(context.Background()) + if err != nil { + t.Fatal(err) + } + if original.externalSigner != nil { + t.Fatal("ResolveSigner must return a prepared copy without mutating the original") + } + + forms := []url.Values{{}, {}} + for _, form := range forms { + used, err := prepared.applyClientAssertion(context.Background(), form, "open.feishu.cn") + if err != nil || !used { + t.Fatalf("applyClientAssertion = used %v err %v", used, err) + } + } + + if resolveCalls != 1 { + t.Fatalf("provider resolution calls = %d, want 1", resolveCalls) + } + if fake.calls != 2 { + t.Fatalf("assertion signing calls = %d, want 2", fake.calls) + } + first := forms[0].Get("client_assertion") + second := forms[1].Get("client_assertion") + if first == "" || second == "" || first == second { + t.Fatalf("assertions = (%q, %q), want two fresh values", first, second) + } + for _, form := range forms { + if form.Has("client_secret") { + t.Fatalf("private_key_jwt form leaked client_secret: %v", form) + } + } +} + +func TestClientAuthFromConfig(t *testing.T) { + ca := ClientAuthFromConfig(&core.CliConfig{ + AppID: "cli_x", + AppSecret: "test-secret", + AuthMethod: core.AuthMethodPrivateKeyJWT, + KeyLabel: "label-1", + }) + if ca.AppID != "cli_x" || ca.AppSecret != "test-secret" || ca.AuthMethod != core.AuthMethodPrivateKeyJWT || ca.KeyLabel != "label-1" { + t.Errorf("ClientAuth = %+v", ca) + } +} diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index 7acdad859c..205c09c07d 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -62,7 +62,7 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints { } // RequestDeviceAuthorization requests a device authorization code. -func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) { +func RequestDeviceAuthorization(ctx context.Context, httpClient *http.Client, ca ClientAuth, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) { if errOut == nil { errOut = io.Discard } @@ -77,18 +77,26 @@ func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string } } - basicAuth := base64.StdEncoding.EncodeToString([]byte(appId + ":" + appSecret)) - form := url.Values{} - form.Set("client_id", appId) + form.Set("client_id", ca.AppID) form.Set("scope", scope) - req, err := http.NewRequest("POST", endpoints.DeviceAuthorization, strings.NewReader(form.Encode())) + // private_key_jwt authenticates the client with a signed assertion in the + // body; client_secret uses HTTP Basic. + usedAssertion, err := ca.applyClientAssertion(ctx, form, core.OpenAPIAudience(brand)) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", endpoints.DeviceAuthorization, strings.NewReader(form.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Authorization", "Basic "+basicAuth) + if !usedAssertion { + basicAuth := base64.StdEncoding.EncodeToString([]byte(ca.AppID + ":" + ca.AppSecret)) + req.Header.Set("Authorization", "Basic "+basicAuth) + } resp, err := httpClient.Do(req) if err != nil { @@ -139,7 +147,7 @@ func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string } // PollDeviceToken polls the token endpoint until authorization completes or times out. -func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult { +func PollDeviceToken(ctx context.Context, httpClient *http.Client, ca ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult { if errOut == nil { errOut = io.Discard } @@ -171,10 +179,16 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec form := url.Values{} form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") form.Set("device_code", deviceCode) - form.Set("client_id", appId) - form.Set("client_secret", appSecret) + form.Set("client_id", ca.AppID) + usedAssertion, caErr := ca.applyClientAssertion(ctx, form, core.OpenAPIAudience(brand)) + if caErr != nil { + return &DeviceFlowResult{OK: false, Error: "invalid_client", Message: caErr.Error()} + } + if !usedAssertion { + form.Set("client_secret", ca.AppSecret) + } - req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode())) + req, err := http.NewRequestWithContext(ctx, "POST", endpoints.Token, strings.NewReader(form.Encode())) if err != nil { continue } diff --git a/internal/auth/device_flow_test.go b/internal/auth/device_flow_test.go index 397098cc18..0c28c82745 100644 --- a/internal/auth/device_flow_test.go +++ b/internal/auth/device_flow_test.go @@ -7,8 +7,10 @@ import ( "bytes" "context" "fmt" + "io" "log" "net/http" + "net/url" "strings" "sync/atomic" "testing" @@ -83,7 +85,7 @@ func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) { }) t.Cleanup(restore) - _, err := RequestDeviceAuthorization(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "", nil) + _, err := RequestDeviceAuthorization(context.Background(), httpmock.NewClient(reg), ClientAuth{AppID: "cli_a", AppSecret: "test-secret"}, core.BrandFeishu, "", nil) if err != nil { t.Fatalf("RequestDeviceAuthorization() error: %v", err) } @@ -106,6 +108,66 @@ func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) { } } +// captureRT records the last request + body and returns a canned device-auth response. +func captureDeviceAuthClient(gotReq **http.Request, gotBody *string, respJSON string) *http.Client { + return &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + *gotReq = req + if req.Body != nil { + b, _ := io.ReadAll(req.Body) + *gotBody = string(b) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(respJSON)), + }, nil + })} +} + +const deviceAuthRespJSON = `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}` + +func TestRequestDeviceAuthorization_PrivateKeyJWT_UsesAssertionNotBasic(t *testing.T) { + var req *http.Request + var body string + client := captureDeviceAuthClient(&req, &body, deviceAuthRespJSON) + + ca := ClientAuth{AppID: "cli_a", AuthMethod: core.AuthMethodPrivateKeyJWT, Signer: newFakeAuthSigner(t), KeyLabel: "k"} + if _, err := RequestDeviceAuthorization(context.Background(), client, ca, core.BrandFeishu, "im:message:send", nil); err != nil { + t.Fatal(err) + } + if req.Header.Get("Authorization") != "" { + t.Errorf("private_key_jwt must NOT send Basic auth, got %q", req.Header.Get("Authorization")) + } + form, _ := url.ParseQuery(body) + if form.Get("client_assertion") == "" { + t.Error("missing client_assertion") + } + if form.Get("client_assertion_type") != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" { + t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type")) + } + if form.Has("client_secret") { + t.Error("client_secret must not be present for private_key_jwt") + } +} + +func TestRequestDeviceAuthorization_ClientSecret_UsesBasic(t *testing.T) { + var req *http.Request + var body string + client := captureDeviceAuthClient(&req, &body, deviceAuthRespJSON) + + ca := ClientAuth{AppID: "cli_a", AppSecret: "test-secret"} // client_secret + if _, err := RequestDeviceAuthorization(context.Background(), client, ca, core.BrandFeishu, "", nil); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(req.Header.Get("Authorization"), "Basic ") { + t.Errorf("client_secret should use Basic auth, got %q", req.Header.Get("Authorization")) + } + form, _ := url.ParseQuery(body) + if form.Has("client_assertion") { + t.Error("client_secret must not send a client_assertion") + } +} + // TestFormatAuthCmdline_TruncatesExtraArgs verifies that long command lines are truncated. func TestFormatAuthCmdline_TruncatesExtraArgs(t *testing.T) { got := keychain.FormatAuthCmdline([]string{ @@ -205,7 +267,7 @@ func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) t.Cleanup(cancel) - result := PollDeviceToken(ctx, client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 0, 10, nil) + result := PollDeviceToken(ctx, client, ClientAuth{AppID: "cli_a", AppSecret: "test-secret"}, core.BrandFeishu, "device-code", 0, 10, nil) if result == nil { t.Fatal("PollDeviceToken() returned nil result") } diff --git a/internal/auth/jwt/jwt.go b/internal/auth/jwt/jwt.go new file mode 100644 index 0000000000..321afd71cc --- /dev/null +++ b/internal/auth/jwt/jwt.go @@ -0,0 +1,152 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package jwt builds compact JWS tokens signed by a keysigner.Signer. +// +// It deliberately depends only on the standard library plus the existing +// google/uuid dependency — no third-party JWT library is introduced, keeping +// go.mod free of new dependencies. The actual signing (and, for ECDSA, the +// ASN.1->r||s conversion) is delegated to the Signer implementation. +package jwt + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/larksuite/cli/internal/keysigner" +) + +func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +// buildSignedJWT builds a compact JWS: +// +// base64url(header).base64url(claims).base64url(signature) +// +// alg is written into the header (it is part of the signed input) and verified +// against the alg the signer reports, guarding against a header/key mismatch. +// typ defaults to "JWT" because the client-assertion endpoint requires that +// protected-header value, even though some protocol examples show only alg. +func buildSignedJWT(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, alg string, header, claims map[string]any) (string, error) { + if signer == nil { + return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)") + } + if header == nil { + header = map[string]any{} + } + header["alg"] = alg + if _, ok := header["typ"]; !ok { + header["typ"] = "JWT" + } + + hb, err := json.Marshal(header) + if err != nil { + return "", fmt.Errorf("jwt: marshal header: %w", err) + } + cb, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("jwt: marshal claims: %w", err) + } + + signingInput := b64(hb) + "." + b64(cb) + sig, gotAlg, err := signer.Sign(ctx, ref, []byte(signingInput)) + if err != nil { + return "", fmt.Errorf("jwt: sign: %w", err) + } + if gotAlg != alg { + return "", fmt.Errorf("jwt: signer alg %q does not match header alg %q", gotAlg, alg) + } + return signingInput + "." + b64(sig), nil +} + +// newJTI returns a random unique token identifier. +func newJTI() string { return uuid.NewString() } + +// attestationTTL bounds the attestation JWT's lifetime. The init nonce (60s, +// single-use) is the real anti-replay constraint; this is a modest margin for +// clock skew on top of the immediate init→sign→begin round-trip. +const attestationTTL = 2 * time.Minute + +// attestationClaims builds the registration attestation claim set per the App +// Registration JWT spec: jti, iat, exp (all required) and the init-issued nonce. +func attestationClaims(nonce string, now time.Time) map[string]any { + return map[string]any{ + "jti": newJTI(), + "iat": now.Unix(), + "exp": now.Add(attestationTTL).Unix(), + "nonce": nonce, + } +} + +// clientAssertionClaims builds an RFC 7523 client_assertion claim set used to +// mint tokens in place of client_secret. aud is the brand's token endpoint URL. +func clientAssertionClaims(clientID, aud string, now time.Time, ttl time.Duration) map[string]any { + return map[string]any{ + "iss": clientID, + "sub": clientID, + "aud": aud, + "iat": now.Unix(), + "exp": now.Add(ttl).Unix(), + "jti": newJTI(), + } +} + +// ClientAssertionType is the RFC 7523 client_assertion_type value used for JWT +// bearer client authentication at the token endpoint. +const ClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + +// defaultAssertionTTL bounds a client_assertion's lifetime. +const defaultAssertionTTL = 5 * time.Minute + +// SignAttestation signs the registration attestation JWT. The public key is +// embedded in the JWS "jwk" header so the registration backend can bind it to +// the app during action=begin; the claims carry the server nonce as a +// proof-of-possession challenge. +func SignAttestation(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, nonce string, now time.Time) (string, error) { + if signer == nil { + return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)") + } + pub, err := signer.EnsureKey(ctx, ref) + if err != nil { + return "", fmt.Errorf("jwt: ensure key: %w", err) + } + alg, err := keysigner.AlgForKey(pub) + if err != nil { + return "", err + } + jwk, err := keysigner.PublicKeyJWK(pub) + if err != nil { + return "", err + } + return buildSignedJWT(ctx, signer, ref, alg, map[string]any{"jwk": jwk}, attestationClaims(nonce, now)) +} + +// SignClientAssertion mints a short-lived RFC 7523 client_assertion: it reads the +// registered key (it must already exist — bound at registration; a missing key is +// an error, not a reason to create a new unbound one), derives the JWS alg from +// the public key, and signs an assertion whose audience is the brand's Open API +// host. The server, holding the public key bound at registration, verifies it in +// place of client_secret. The assertion header carries only alg (no jwk/kid); +// the server locates the key via iss/sub = client_id. +// +// This is the model-independent glue: the assertion JWT is identical whether the +// server augments an existing grant (device_code/refresh_token) with client +// authentication or uses a dedicated jwt-bearer grant — only where the caller +// attaches it differs. +func SignClientAssertion(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, clientID, audience string, now time.Time) (string, error) { + if signer == nil { + return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)") + } + pub, err := signer.PublicKey(ctx, ref) + if err != nil { + return "", fmt.Errorf("jwt: public key: %w", err) + } + alg, err := keysigner.AlgForKey(pub) + if err != nil { + return "", err + } + return buildSignedJWT(ctx, signer, ref, alg, map[string]any{}, clientAssertionClaims(clientID, audience, now, defaultAssertionTTL)) +} diff --git a/internal/auth/jwt/jwt_test.go b/internal/auth/jwt/jwt_test.go new file mode 100644 index 0000000000..d8d558a97d --- /dev/null +++ b/internal/auth/jwt/jwt_test.go @@ -0,0 +1,254 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package jwt + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "math/big" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/keysigner" +) + +// fakeSigner is a real in-memory ECDSA P-256 signer, so tests exercise the full +// JWS path and the produced token is actually cryptographically verifiable. +type fakeSigner struct{ key *ecdsa.PrivateKey } + +func newFakeSigner(t *testing.T) *fakeSigner { + t.Helper() + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + return &fakeSigner{key: k} +} + +func (f *fakeSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) { + return f.key.Public(), nil +} +func (f *fakeSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) { + return f.key.Public(), nil +} +func (f *fakeSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) { + h := sha256.Sum256(in) + r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:]) + if err != nil { + return nil, "", err + } + // JOSE ES256: fixed-width big-endian r||s (32 bytes each for P-256). + sig := make([]byte, 64) + r.FillBytes(sig[:32]) + s.FillBytes(sig[32:]) + return sig, keysigner.AlgES256, nil +} + +func TestBuildSignedJWT_VerifiableES256(t *testing.T) { + f := newFakeSigner(t) + now := time.Unix(1700000000, 0) + + tok, err := buildSignedJWT(context.Background(), f, keysigner.KeyRef{Label: "x"}, keysigner.AlgES256, + map[string]any{}, clientAssertionClaims("cli_app", "https://accounts.example/token", now, 5*time.Minute)) + if err != nil { + t.Fatal(err) + } + + parts := strings.Split(tok, ".") + if len(parts) != 3 { + t.Fatalf("want 3 JWS parts, got %d", len(parts)) + } + + hb, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + t.Fatalf("header not base64url: %v", err) + } + var hdr map[string]any + if err := json.Unmarshal(hb, &hdr); err != nil { + t.Fatal(err) + } + if hdr["alg"] != "ES256" || hdr["typ"] != "JWT" { + t.Errorf("header = %v, want alg=ES256 typ=JWT", hdr) + } + + cb, _ := base64.RawURLEncoding.DecodeString(parts[1]) + var claims map[string]any + if err := json.Unmarshal(cb, &claims); err != nil { + t.Fatal(err) + } + if claims["iss"] != "cli_app" || claims["sub"] != "cli_app" || claims["aud"] != "https://accounts.example/token" { + t.Errorf("claims = %v", claims) + } + + // Cryptographically verify the signature against the signing input. + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + t.Fatalf("sig not base64url: %v", err) + } + if len(sig) != 64 { + t.Fatalf("ES256 sig len = %d, want 64", len(sig)) + } + r := new(big.Int).SetBytes(sig[:32]) + s := new(big.Int).SetBytes(sig[32:]) + h := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) { + t.Error("signature did not verify") + } +} + +func TestBuildSignedJWT_NilSigner(t *testing.T) { + if _, err := buildSignedJWT(context.Background(), nil, keysigner.KeyRef{}, "ES256", nil, nil); err == nil { + t.Fatal("expected error for nil signer") + } +} + +func TestBuildSignedJWT_AlgMismatch(t *testing.T) { + f := newFakeSigner(t) // always reports ES256 + if _, err := buildSignedJWT(context.Background(), f, keysigner.KeyRef{}, keysigner.AlgRS256, nil, nil); err == nil { + t.Fatal("expected error when header alg != signer alg") + } +} + +func TestBuildSignedJWT_MarshalErrors(t *testing.T) { + f := newFakeSigner(t) + ctx := context.Background() + + _, err := buildSignedJWT(ctx, f, keysigner.KeyRef{}, keysigner.AlgES256, + map[string]any{"bad": func() {}}, nil) + if err == nil || !strings.Contains(err.Error(), "jwt: marshal header") { + t.Fatalf("header marshal error = %v, want prefix %q", err, "jwt: marshal header") + } + + _, err = buildSignedJWT(ctx, f, keysigner.KeyRef{}, keysigner.AlgES256, + nil, map[string]any{"bad": make(chan int)}) + if err == nil || !strings.Contains(err.Error(), "jwt: marshal claims") { + t.Fatalf("claims marshal error = %v, want prefix %q", err, "jwt: marshal claims") + } +} + +func TestSignClientAssertion(t *testing.T) { + f := newFakeSigner(t) + now := time.Unix(1700000000, 0) + const aud = "https://accounts.feishu.cn/open-apis/authen/v2/oauth/token" + + tok, err := SignClientAssertion(context.Background(), f, keysigner.KeyRef{Label: "k"}, "cli_app", aud, now) + if err != nil { + t.Fatal(err) + } + + parts := strings.Split(tok, ".") + if len(parts) != 3 { + t.Fatalf("want 3 parts, got %d", len(parts)) + } + cb, _ := base64.RawURLEncoding.DecodeString(parts[1]) + var claims map[string]any + if err := json.Unmarshal(cb, &claims); err != nil { + t.Fatal(err) + } + if claims["iss"] != "cli_app" || claims["aud"] != aud { + t.Errorf("claims = %v", claims) + } + + // Signature must verify against the key's public half. + sig, _ := base64.RawURLEncoding.DecodeString(parts[2]) + r := new(big.Int).SetBytes(sig[:32]) + s := new(big.Int).SetBytes(sig[32:]) + h := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) { + t.Error("client_assertion signature did not verify") + } +} + +func TestSignClientAssertion_NilSigner(t *testing.T) { + if _, err := SignClientAssertion(context.Background(), nil, keysigner.KeyRef{}, "cli_app", "aud", time.Unix(0, 0)); err == nil { + t.Fatal("expected error for nil signer") + } +} + +func TestSignAttestation(t *testing.T) { + f := newFakeSigner(t) + now := time.Unix(1700000000, 0) + + tok, err := SignAttestation(context.Background(), f, keysigner.KeyRef{Label: "k"}, "nonce-abc", now) + if err != nil { + t.Fatal(err) + } + parts := strings.Split(tok, ".") + if len(parts) != 3 { + t.Fatalf("want 3 parts, got %d", len(parts)) + } + + hb, _ := base64.RawURLEncoding.DecodeString(parts[0]) + var hdr map[string]any + if err := json.Unmarshal(hb, &hdr); err != nil { + t.Fatal(err) + } + jwk, ok := hdr["jwk"].(map[string]any) + if !ok { + t.Fatalf("attestation header missing jwk: %v", hdr) + } + if jwk["kty"] != "EC" || jwk["crv"] != "P-256" || jwk["use"] != "sig" { + t.Errorf("jwk = %v", jwk) + } + + cb, _ := base64.RawURLEncoding.DecodeString(parts[1]) + var claims map[string]any + if err := json.Unmarshal(cb, &claims); err != nil { + t.Fatal(err) + } + if claims["nonce"] != "nonce-abc" { + t.Errorf("nonce = %v", claims["nonce"]) + } + // jti, iat, exp are all required by the attestation spec. + iat, iatOK := claims["iat"].(float64) + exp, expOK := claims["exp"].(float64) + if !iatOK || !expOK || exp <= iat { + t.Errorf("claims iat/exp invalid: iat=%v exp=%v", claims["iat"], claims["exp"]) + } + if jti, _ := claims["jti"].(string); jti == "" { + t.Error("claims jti empty") + } + + // Signature verifies against the embedded key. + sig, _ := base64.RawURLEncoding.DecodeString(parts[2]) + r := new(big.Int).SetBytes(sig[:32]) + s := new(big.Int).SetBytes(sig[32:]) + h := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) { + t.Error("attestation signature did not verify") + } +} + +func TestSignAttestation_NilSigner(t *testing.T) { + if _, err := SignAttestation(context.Background(), nil, keysigner.KeyRef{}, "n", time.Unix(0, 0)); err == nil { + t.Fatal("expected error for nil signer") + } +} + +func TestClaimFactories(t *testing.T) { + now := time.Unix(1700000000, 0) + + a := attestationClaims("nonce-xyz", now) + if a["nonce"] != "nonce-xyz" || a["iat"] != now.Unix() { + t.Errorf("attestation claims = %v", a) + } + if a["exp"] != now.Add(attestationTTL).Unix() { + t.Errorf("attestation exp = %v, want %v", a["exp"], now.Add(attestationTTL).Unix()) + } + if jti, _ := a["jti"].(string); jti == "" { + t.Error("attestation jti empty") + } + + c := clientAssertionClaims("cli_app", "aud", now, time.Minute) + if c["exp"].(int64) != now.Add(time.Minute).Unix() { + t.Errorf("client_assertion exp = %v", c["exp"]) + } +} diff --git a/internal/auth/uat_client.go b/internal/auth/uat_client.go index d1a0683927..eea0583c41 100644 --- a/internal/auth/uat_client.go +++ b/internal/auth/uat_client.go @@ -21,6 +21,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/errclass" + "github.com/larksuite/cli/internal/keysigner" "github.com/larksuite/cli/internal/vfs" ) @@ -33,11 +34,15 @@ func sanitizeID(id string) string { // UATCallOptions contains options for UAT API calls. type UATCallOptions struct { - UserOpenId string - AppId string - AppSecret string - Domain core.LarkBrand - ErrOut io.Writer // diagnostic/status output (caller injects f.IOStreams.ErrOut) + UserOpenId string + AppId string + AppSecret string + Domain core.LarkBrand + AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT + KeyLabel string // TEE key handle for private_key_jwt + KeyProvider string // empty == built-in signer; explicit external route otherwise + Signer keysigner.Signer // active signer for private_key_jwt + ErrOut io.Writer // diagnostic/status output (caller injects f.IOStreams.ErrOut) } // UATStatus represents the status of a user access token. @@ -57,11 +62,15 @@ func NewUATCallOptions(cfg *core.CliConfig, errOut io.Writer) UATCallOptions { errOut = os.Stderr } return UATCallOptions{ - UserOpenId: cfg.UserOpenId, - AppId: cfg.AppID, - AppSecret: cfg.AppSecret, - Domain: cfg.Brand, - ErrOut: errOut, + UserOpenId: cfg.UserOpenId, + AppId: cfg.AppID, + AppSecret: cfg.AppSecret, + Domain: cfg.Brand, + AuthMethod: cfg.AuthMethod, + KeyLabel: cfg.KeyLabel, + KeyProvider: cfg.KeyProvider, + Signer: keysigner.Active(), + ErrOut: errOut, } } @@ -187,13 +196,31 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored } endpoints := ResolveOAuthEndpoints(opts.Domain) + clientAuth := ClientAuth{ + AppID: opts.AppId, + AppSecret: opts.AppSecret, + AuthMethod: opts.AuthMethod, + Signer: opts.Signer, + KeyLabel: opts.KeyLabel, + KeyProvider: opts.KeyProvider, + } + clientAuth, err := clientAuth.ResolveSigner(context.Background()) + if err != nil { + return nil, err + } callEndpoint := func() (map[string]interface{}, error) { form := url.Values{} form.Set("grant_type", "refresh_token") form.Set("refresh_token", stored.RefreshToken) form.Set("client_id", opts.AppId) - form.Set("client_secret", opts.AppSecret) + usedAssertion, caErr := clientAuth.applyClientAssertion(context.Background(), form, core.OpenAPIAudience(opts.Domain)) + if caErr != nil { + return nil, caErr + } + if !usedAssertion { + form.Set("client_secret", opts.AppSecret) + } req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode())) if err != nil { diff --git a/internal/auth/uat_client_options_test.go b/internal/auth/uat_client_options_test.go index d1f8257735..e7010870c2 100644 --- a/internal/auth/uat_client_options_test.go +++ b/internal/auth/uat_client_options_test.go @@ -38,3 +38,27 @@ func TestNewUATCallOptions(t *testing.T) { t.Error("ErrOut not set correctly") } } + +// TestNewUATCallOptions_PrivateKeyJWT verifies the auth-method fields propagate +// so the refresh path can mint a client_assertion instead of sending a secret. +func TestNewUATCallOptions_PrivateKeyJWT(t *testing.T) { + cfg := &core.CliConfig{ + AppID: "cli_pk", + Brand: core.BrandFeishu, + UserOpenId: "ou_test", + AuthMethod: core.AuthMethodPrivateKeyJWT, + KeyLabel: "agent-key", + KeyProvider: core.KeylessProviderLarkSuite, + } + opts := NewUATCallOptions(cfg, &bytes.Buffer{}) + + if opts.AuthMethod != core.AuthMethodPrivateKeyJWT { + t.Errorf("AuthMethod = %q, want private_key_jwt", opts.AuthMethod) + } + if opts.KeyLabel != "agent-key" { + t.Errorf("KeyLabel = %q, want agent-key", opts.KeyLabel) + } + if opts.KeyProvider != core.KeylessProviderLarkSuite { + t.Errorf("KeyProvider = %q, want %q", opts.KeyProvider, core.KeylessProviderLarkSuite) + } +} diff --git a/internal/auth/uat_client_test.go b/internal/auth/uat_client_test.go new file mode 100644 index 0000000000..d61a6ca261 --- /dev/null +++ b/internal/auth/uat_client_test.go @@ -0,0 +1,122 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/auth/jwt" + "github.com/larksuite/cli/internal/core" +) + +type uatRoundTripFunc func(*http.Request) (*http.Response, error) + +func (fn uatRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +type retryExternalAssertionSigner struct { + calls int +} + +func (s *retryExternalAssertionSigner) SignClientAssertion(_ context.Context, _, _, _ string) (string, string, error) { + s.calls++ + return jwt.ClientAssertionType, fmt.Sprintf("refresh.jwt.%d", s.calls), nil +} + +func TestDoRefreshToken_PrivateKeyJWTRetryResolvesOnceAndRemintsAssertion(t *testing.T) { + signer := &retryExternalAssertionSigner{} + resolveCalls := 0 + previous := resolveExternalAssertionSigner + resolveExternalAssertionSigner = func(_ context.Context, provider string) (clientAssertionSigner, error) { + resolveCalls++ + if provider != core.KeylessProviderLarkSuite { + t.Fatalf("provider = %q", provider) + } + return signer, nil + } + t.Cleanup(func() { resolveExternalAssertionSigner = previous }) + + var forms []url.Values + httpClient := &http.Client{Transport: uatRoundTripFunc(func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + form, err := url.ParseQuery(string(body)) + if err != nil { + return nil, err + } + forms = append(forms, form) + + responseBody := `{"code":20050,"error":"server_error","error_description":"retry"}` + if len(forms) == 2 { + // A success-shaped response without a token lets the test exercise the + // retry without writing platform keychain state. + responseBody = `{"code":0}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(responseBody)), + Request: req, + }, nil + })} + + now := time.Now().UnixMilli() + stored := &StoredUAToken{ + UserOpenId: "ou_test", + AppId: "cli_external", + RefreshToken: "refresh-token", + RefreshExpiresAt: now + int64(time.Hour/time.Millisecond), + Scope: "offline_access", + GrantedAt: now, + } + opts := UATCallOptions{ + UserOpenId: stored.UserOpenId, + AppId: stored.AppId, + Domain: core.BrandFeishu, + AuthMethod: core.AuthMethodPrivateKeyJWT, + KeyLabel: "openclaw-lark", + KeyProvider: core.KeylessProviderLarkSuite, + ErrOut: io.Discard, + } + + updated, err := doRefreshToken(httpClient, opts, stored) + if err == nil || !strings.Contains(err.Error(), "no access_token") { + t.Fatalf("doRefreshToken error = %v, want missing access_token after retry", err) + } + if updated != nil { + t.Fatalf("updated token = %#v, want nil", updated) + } + if resolveCalls != 1 { + t.Fatalf("provider resolution calls = %d, want 1", resolveCalls) + } + if signer.calls != 2 { + t.Fatalf("assertion signing calls = %d, want 2", signer.calls) + } + if len(forms) != 2 { + t.Fatalf("token endpoint requests = %d, want 2", len(forms)) + } + first := forms[0].Get("client_assertion") + second := forms[1].Get("client_assertion") + if first == "" || second == "" || first == second { + t.Fatalf("assertions = (%q, %q), want two fresh values", first, second) + } + for _, form := range forms { + if form.Get("grant_type") != "refresh_token" { + t.Fatalf("grant_type = %q, want refresh_token", form.Get("grant_type")) + } + if form.Has("client_secret") { + t.Fatalf("private_key_jwt form leaked client_secret: %v", form) + } + } +} diff --git a/internal/binding/keyless_types_test.go b/internal/binding/keyless_types_test.go new file mode 100644 index 0000000000..962ad7949a --- /dev/null +++ b/internal/binding/keyless_types_test.go @@ -0,0 +1,112 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package binding + +import "testing" + +func TestListCandidateApps_KeylessSingleAccount(t *testing.T) { + apps := ListCandidateApps(&FeishuChannel{ + AppID: "cli_keyless", + Brand: "feishu", + AuthMethod: AuthMethodPrivateKeyJWT, + KeyRef: "openclaw-lark", + }) + if len(apps) != 1 { + t.Fatalf("count = %d, want 1", len(apps)) + } + if !apps[0].IsKeyless() { + t.Fatalf("candidate = %#v, want keyless", apps[0]) + } + if apps[0].KeyRef != "openclaw-lark" { + t.Fatalf("KeyRef = %q, want openclaw-lark", apps[0].KeyRef) + } +} + +func TestListCandidateApps_KeylessMultiAccountInheritance(t *testing.T) { + apps := ListCandidateApps(&FeishuChannel{ + AppID: "cli_top", + Brand: "lark", + AuthMethod: AuthMethodPrivateKeyJWT, + KeyRef: "openclaw-lark", + Accounts: map[string]*FeishuAccount{ + "work": {}, + }, + }) + if len(apps) != 2 { + t.Fatalf("count = %d, want 2 (implicit default + work)", len(apps)) + } + app := candidateByLabel(t, apps, "work") + if app.Label != "work" || app.AppID != "cli_top" || app.Brand != "lark" { + t.Fatalf("candidate identity = %#v", app) + } + if app.AuthMethod != AuthMethodPrivateKeyJWT || app.KeyRef != "openclaw-lark" || !app.IsKeyless() { + t.Fatalf("candidate keyless fields = %#v", app) + } +} + +func TestListCandidateApps_KeylessAccountOverride(t *testing.T) { + apps := ListCandidateApps(&FeishuChannel{ + AppID: "cli_top", + AuthMethod: AuthMethodPrivateKeyJWT, + KeyRef: "top-key", + Accounts: map[string]*FeishuAccount{ + "work": {AppID: "cli_work", KeyRef: "work-key"}, + }, + }) + if len(apps) != 2 { + t.Fatalf("count = %d, want 2 (implicit default + work)", len(apps)) + } + if got := candidateByLabel(t, apps, "work"); got.AppID != "cli_work" || got.KeyRef != "work-key" || !got.IsKeyless() { + t.Fatalf("candidate = %#v", got) + } +} + +func candidateByLabel(t *testing.T, apps []CandidateApp, label string) CandidateApp { + t.Helper() + for _, app := range apps { + if app.Label == label { + return app + } + } + t.Fatalf("candidate %q not found in %#v", label, apps) + return CandidateApp{} +} + +func TestCandidateApp_SecretTakesPrecedenceOverKeyless(t *testing.T) { + app := CandidateApp{ + AppID: "cli_both", + AppSecret: SecretInput{Plain: "secret"}, + AuthMethod: AuthMethodPrivateKeyJWT, + KeyRef: "openclaw-lark", + } + if app.IsKeyless() { + t.Fatal("an appSecret-backed OpenClaw account must not be treated as keyless") + } +} + +func TestCandidateApp_KeylessRequiresKeyRef(t *testing.T) { + app := CandidateApp{AppID: "cli_missing_key", AuthMethod: AuthMethodPrivateKeyJWT} + if app.IsKeyless() { + t.Fatal("private_key_jwt without keyRef must not be treated as usable keyless") + } +} + +func TestListCandidateApps_KeylessImplicitDefault(t *testing.T) { + apps := ListCandidateApps(&FeishuChannel{ + AppID: "cli_default", + AuthMethod: AuthMethodPrivateKeyJWT, + KeyRef: "openclaw-lark", + Accounts: map[string]*FeishuAccount{ + "work": {AppID: "cli_work", AuthMethod: AuthMethodPrivateKeyJWT, KeyRef: "work-key"}, + }, + }) + if len(apps) != 2 { + t.Fatalf("count = %d, want 2", len(apps)) + } + for _, app := range apps { + if !app.IsKeyless() { + t.Fatalf("candidate = %#v, want keyless", app) + } + } +} diff --git a/internal/binding/types.go b/internal/binding/types.go index 8bf83813ce..e35e1d5a42 100644 --- a/internal/binding/types.go +++ b/internal/binding/types.go @@ -31,20 +31,24 @@ type ChannelsRoot struct { // `Brand` stays aligned with our internal terminology, but the JSON // tag matches OpenClaw's on-disk format. type FeishuChannel struct { - Enabled *bool `json:"enabled,omitempty"` // nil = default enabled - AppID string `json:"appId,omitempty"` - AppSecret SecretInput `json:"appSecret,omitempty"` - Brand string `json:"domain,omitempty"` - Accounts map[string]*FeishuAccount `json:"accounts,omitempty"` + Enabled *bool `json:"enabled,omitempty"` // nil = default enabled + AppID string `json:"appId,omitempty"` + AppSecret SecretInput `json:"appSecret,omitempty"` + Brand string `json:"domain,omitempty"` + Accounts map[string]*FeishuAccount `json:"accounts,omitempty"` + AuthMethod string `json:"authMethod,omitempty"` + KeyRef string `json:"keyRef,omitempty"` } // FeishuAccount is a single account entry within Accounts. // Like FeishuChannel, `Brand` maps to OpenClaw's `domain` key. type FeishuAccount struct { - Enabled *bool `json:"enabled,omitempty"` // nil = default enabled - AppID string `json:"appId,omitempty"` - AppSecret SecretInput `json:"appSecret,omitempty"` - Brand string `json:"domain,omitempty"` + Enabled *bool `json:"enabled,omitempty"` // nil = default enabled + AppID string `json:"appId,omitempty"` + AppSecret SecretInput `json:"appSecret,omitempty"` + Brand string `json:"domain,omitempty"` + AuthMethod string `json:"authMethod,omitempty"` + KeyRef string `json:"keyRef,omitempty"` } // isEnabled returns true if the enabled field is nil (default) or explicitly true. @@ -228,10 +232,25 @@ func LookupProvider(ref *SecretRef, cfg *SecretsConfig) (*ProviderConfig, error) // CandidateApp represents a bindable app from OpenClaw's feishu channel config. type CandidateApp struct { - Label string - AppID string - AppSecret SecretInput - Brand string + Label string + AppID string + AppSecret SecretInput + Brand string + AuthMethod string + KeyRef string +} + +const AuthMethodPrivateKeyJWT = "private_key_jwt" + +// IsKeyless mirrors openclaw-lark's resolved-account precedence: an app +// secret wins when both credential shapes are present. Only a secretless +// private_key_jwt account with a keyRef is eligible for helper reuse. +func (c CandidateApp) IsKeyless() bool { + return c.AppSecret.IsZero() && c.AuthMethod == AuthMethodPrivateKeyJWT && strings.TrimSpace(c.KeyRef) != "" +} + +func bindableCredential(secret SecretInput, authMethod, keyRef string) bool { + return !secret.IsZero() || (authMethod == AuthMethodPrivateKeyJWT && strings.TrimSpace(keyRef) != "") } // ListCandidateApps enumerates all bindable (enabled) apps from a FeishuChannel. @@ -243,7 +262,7 @@ func ListCandidateApps(ch *FeishuChannel) []CandidateApp { if len(ch.Accounts) > 0 { apps := make([]CandidateApp, 0, len(ch.Accounts)+1) - // When accounts exist AND top-level has its own appId+appSecret, + // When accounts exist AND top-level has its own bindable credential, // include the top-level as a "default" candidate — aligned with // openclaw-lark getLarkAccountIds() which adds DEFAULT_ACCOUNT_ID // when top-level credentials are present and no explicit "default" exists. @@ -254,12 +273,15 @@ func ListCandidateApps(ch *FeishuChannel) []CandidateApp { break } } - if !hasDefault && ch.AppID != "" && !ch.AppSecret.IsZero() && isEnabled(ch.Enabled) { + if !hasDefault && ch.AppID != "" && isEnabled(ch.Enabled) && + bindableCredential(ch.AppSecret, ch.AuthMethod, ch.KeyRef) { apps = append(apps, CandidateApp{ - Label: "default", - AppID: ch.AppID, - AppSecret: ch.AppSecret, - Brand: ch.Brand, + Label: "default", + AppID: ch.AppID, + AppSecret: ch.AppSecret, + Brand: ch.Brand, + AuthMethod: ch.AuthMethod, + KeyRef: ch.KeyRef, }) } @@ -282,11 +304,21 @@ func ListCandidateApps(ch *FeishuChannel) []CandidateApp { if brand == "" { brand = ch.Brand } + authMethod := acct.AuthMethod + if authMethod == "" { + authMethod = ch.AuthMethod + } + keyRef := acct.KeyRef + if keyRef == "" { + keyRef = ch.KeyRef + } apps = append(apps, CandidateApp{ - Label: label, - AppID: appID, - AppSecret: appSecret, - Brand: brand, + Label: label, + AppID: appID, + AppSecret: appSecret, + Brand: brand, + AuthMethod: authMethod, + KeyRef: keyRef, }) } return apps @@ -295,10 +327,12 @@ func ListCandidateApps(ch *FeishuChannel) []CandidateApp { // Single account at top level — check if channel itself is enabled if ch.AppID != "" && isEnabled(ch.Enabled) { return []CandidateApp{{ - Label: "", - AppID: ch.AppID, - AppSecret: ch.AppSecret, - Brand: ch.Brand, + Label: "", + AppID: ch.AppID, + AppSecret: ch.AppSecret, + Brand: ch.Brand, + AuthMethod: ch.AuthMethod, + KeyRef: ch.KeyRef, }} } diff --git a/internal/core/config.go b/internal/core/config.go index 22d83c5d02..216e2f80d7 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -36,6 +36,14 @@ type AppUser struct { UserName string `json:"userName"` } +// Auth methods for app credentials. An empty AppConfig.AuthMethod means the +// default, client_secret. +const ( + AuthMethodClientSecret = "client_secret" // app_id + app_secret + authMethodPKJWTValue = "private_key_jwt" // TEE-signed client_assertion; no app secret + AuthMethodPrivateKeyJWT = authMethodPKJWTValue +) + // AppConfig is a per-app configuration entry (stored format — secrets may be unresolved). type AppConfig struct { Name string `json:"name,omitempty"` @@ -46,6 +54,15 @@ type AppConfig struct { DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto StrictMode *StrictMode `json:"strictMode,omitempty"` Users []AppUser `json:"users"` + + // AuthMethod selects how tokens are minted. Empty == AuthMethodClientSecret + // (back-compat). AuthMethodPrivateKeyJWT uses a TEE-held key (see KeyRef) to + // sign client_assertion JWTs instead of sending an app secret. + AuthMethod string `json:"authMethod,omitempty"` + // KeyRef references the non-exportable signing key for private_key_jwt. + // Source is "tee" and ID is the backend key label; the actual key never + // leaves the secure backend, so this is a handle, not secret material. + KeyRef *SecretRef `json:"keyRef,omitempty"` } // ProfileName returns the display name for this app config. @@ -161,7 +178,10 @@ type CliConfig struct { UserOpenId string UserName string Lang i18n.Lang - SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider + SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider + AuthMethod string // "" == client_secret; AuthMethodPrivateKeyJWT + KeyLabel string // resolved TEE key handle for private_key_jwt + KeyProvider string // empty == built-in signer; otherwise an explicit external signer route } // identityBotBit is the bit flag for bot identity in SupportedIdentities. @@ -247,31 +267,67 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro WithHint("available profiles: %s", formatProfileNames(raw.ProfileNames())) } - if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil { - return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync"). - WithHint("%s", err.Error()). - WithCause(err) + // Validate the auth method first so a malformed profile fails here rather + // than silently degrading to client_secret (unknown method) or failing later + // at token-signing. Empty stays empty — downstream treats it as client_secret + // (back-compat). + switch app.AuthMethod { + case "", AuthMethodClientSecret, AuthMethodPrivateKeyJWT: + default: + return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "unknown authMethod %q", app.AuthMethod). + WithHint("supported: %s, %s (empty defaults to %s)", AuthMethodClientSecret, AuthMethodPrivateKeyJWT, AuthMethodClientSecret) } - secret, err := ResolveSecretInput(app.AppSecret, kc) - if err != nil { - if errs.IsTyped(err) { - return nil, err + // private_key_jwt carries no secret: validate the key handle and skip secret + // resolution entirely, so a stale/broken AppSecret ref never produces a + // confusing secret-resolution error for an otherwise-valid pkjwt profile. + var secret string + if app.AuthMethod == AuthMethodPrivateKeyJWT { + if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID == "" { + return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "private_key_jwt requires a key handle (keyRef) but none is configured"). + WithHint("re-run: lark-cli config init --new --private-key-jwt") } - subtype := errs.SubtypeNotConfigured - if isMalformedConfigError(err) { - subtype = errs.SubtypeInvalidConfig + provider := strings.TrimSpace(app.KeyRef.Provider) + switch provider { + case "", KeylessProviderLarkSuite: + default: + return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, + "unknown keyless signer provider %q", app.KeyRef.Provider). + WithHint("supported external provider: %s; omit provider to use the built-in signer", KeylessProviderLarkSuite) + } + } else { + if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil { + return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync"). + WithHint("%s", err.Error()). + WithCause(err) + } + var resolveErr error + secret, resolveErr = ResolveSecretInput(app.AppSecret, kc) + if resolveErr != nil { + if errs.IsTyped(resolveErr) { + return nil, resolveErr + } + subtype := errs.SubtypeNotConfigured + if isMalformedConfigError(resolveErr) { + subtype = errs.SubtypeInvalidConfig + } + return nil, errs.NewConfigError(subtype, "%s", resolveErr.Error()).WithCause(resolveErr) } - return nil, errs.NewConfigError(subtype, "%s", err.Error()).WithCause(err) } + cfg := &CliConfig{ ProfileName: app.ProfileName(), AppID: app.AppId, AppSecret: secret, Brand: ParseBrand(string(app.Brand)), Lang: app.Lang, + AuthMethod: app.AuthMethod, DefaultAs: app.DefaultAs, } + if app.KeyRef != nil { + cfg.KeyLabel = app.KeyRef.ID + cfg.KeyProvider = strings.TrimSpace(app.KeyRef.Provider) + } if len(app.Users) > 0 { cfg.UserOpenId = app.Users[0].UserOpenId cfg.UserName = app.Users[0].UserName diff --git a/internal/core/config_test.go b/internal/core/config_test.go index b9b6fdd947..4093ddeaf9 100644 --- a/internal/core/config_test.go +++ b/internal/core/config_test.go @@ -86,6 +86,35 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) { } } +func TestResolveConfigFromMulti_KeyProviderRouting(t *testing.T) { + base := AppConfig{ + AppId: "cli_pk", Brand: BrandFeishu, AuthMethod: AuthMethodPrivateKeyJWT, + KeyRef: &SecretRef{Source: SecretSourceTEE, ID: "key-1"}, Users: []AppUser{}, + } + + for _, provider := range []string{"", KeylessProviderLarkSuite} { + app := base + ref := *base.KeyRef + ref.Provider = provider + app.KeyRef = &ref + cfg, err := ResolveConfigFromMulti(&MultiAppConfig{Apps: []AppConfig{app}}, stubKeychain{}, "") + if err != nil { + t.Fatalf("provider %q: %v", provider, err) + } + if cfg.KeyProvider != provider { + t.Fatalf("KeyProvider = %q, want %q", cfg.KeyProvider, provider) + } + } + + app := base + ref := *base.KeyRef + ref.Provider = "unknown.provider" + app.KeyRef = &ref + if _, err := ResolveConfigFromMulti(&MultiAppConfig{Apps: []AppConfig{app}}, stubKeychain{}, ""); err == nil { + t.Fatal("unknown provider must fail closed") + } +} + func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) { raw := &MultiAppConfig{ Apps: []AppConfig{ @@ -133,6 +162,108 @@ func TestResolveConfigFromMulti_AcceptsPlainSecret(t *testing.T) { } } +// TestResolveConfigFromMulti_RejectsUnknownAuthMethod ensures an unsupported +// authMethod fails at resolution rather than silently degrading to client_secret. +func TestResolveConfigFromMulti_RejectsUnknownAuthMethod(t *testing.T) { + raw := &MultiAppConfig{ + Apps: []AppConfig{ + { + AppId: "cli_abc", + AppSecret: PlainSecret("my-secret"), + Brand: BrandFeishu, + AuthMethod: "bogus_method", + }, + }, + } + + _, err := ResolveConfigFromMulti(raw, nil, "") + if err == nil { + t.Fatal("expected error for unknown authMethod") + } + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected ConfigError, got %T: %v", err, err) + } +} + +// TestResolveConfigFromMulti_PrivateKeyJWTRequiresKeyRef ensures private_key_jwt +// without a key handle fails at resolution rather than later at token-signing. +func TestResolveConfigFromMulti_PrivateKeyJWTRequiresKeyRef(t *testing.T) { + raw := &MultiAppConfig{ + Apps: []AppConfig{ + { + AppId: "cli_abc", + AppSecret: SecretInput{}, // private_key_jwt carries no app secret + Brand: BrandFeishu, + AuthMethod: AuthMethodPrivateKeyJWT, + // KeyRef intentionally nil + }, + }, + } + + _, err := ResolveConfigFromMulti(raw, nil, "") + if err == nil { + t.Fatal("expected error for private_key_jwt without keyRef") + } + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected ConfigError, got %T: %v", err, err) + } + + // Control: same config WITH a keyRef resolves cleanly and sets KeyLabel. + raw.Apps[0].KeyRef = &SecretRef{Source: "tee", ID: "larksuite-cli-agent"} + cfg, err := ResolveConfigFromMulti(raw, nil, "") + if err != nil { + t.Fatalf("unexpected error with keyRef present: %v", err) + } + if cfg.KeyLabel != "larksuite-cli-agent" { + t.Errorf("KeyLabel = %q, want larksuite-cli-agent", cfg.KeyLabel) + } +} + +// TestResolveConfigFromMulti_PKJWTSkipsSecretResolution ensures a private_key_jwt +// profile that carries a stale/broken AppSecret ref still resolves cleanly: the +// auth method is judged before any secret handling, so the stale ref is ignored +// instead of producing a confusing secret-resolution failure. +func TestResolveConfigFromMulti_PKJWTSkipsSecretResolution(t *testing.T) { + raw := &MultiAppConfig{ + Apps: []AppConfig{{ + AppId: "cli_pk", + // Stale keychain ref whose ID does not match appId — would trip + // ValidateSecretKeyMatch / ResolveSecretInput if it were reached. + AppSecret: SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_OTHER"}}, + Brand: BrandFeishu, + AuthMethod: AuthMethodPrivateKeyJWT, + KeyRef: &SecretRef{Source: "tee", ID: "agent-key"}, + Users: []AppUser{}, + }}, + } + cfg, err := ResolveConfigFromMulti(raw, stubKeychain{}, "") + if err != nil { + t.Fatalf("pkjwt with stale secret ref must skip secret resolution, got %v", err) + } + if cfg.AuthMethod != AuthMethodPrivateKeyJWT || cfg.KeyLabel != "agent-key" { + t.Errorf("got authMethod=%q keyLabel=%q", cfg.AuthMethod, cfg.KeyLabel) + } +} + +// TestResolveConfigFromMulti_PKJWTRejectsBadKeyRef ensures the stricter keyRef +// check (Source=="tee" && ID!="") rejects malformed handles. +func TestResolveConfigFromMulti_PKJWTRejectsBadKeyRef(t *testing.T) { + for i, ref := range []*SecretRef{ + {Source: "keychain", ID: "x"}, // wrong source + {Source: "tee", ID: ""}, // empty id + } { + raw := &MultiAppConfig{Apps: []AppConfig{{ + AppId: "cli_pk", Brand: BrandFeishu, + AuthMethod: AuthMethodPrivateKeyJWT, KeyRef: ref, Users: []AppUser{}, + }}} + if _, err := ResolveConfigFromMulti(raw, stubKeychain{}, ""); err == nil { + t.Errorf("case %d: expected ConfigError for bad keyRef", i) + } + } +} + func TestResolveConfigFromMulti_CarriesLang(t *testing.T) { raw := &MultiAppConfig{ Apps: []AppConfig{ diff --git a/internal/core/secret.go b/internal/core/secret.go index a488e5dcf7..6ba26f4d9f 100644 --- a/internal/core/secret.go +++ b/internal/core/secret.go @@ -19,6 +19,12 @@ type SecretRef struct { ID string `json:"id"` // env var name / file path / command / keychain key } +// KeylessProviderLarkSuite is the only external private_key_jwt signer route. +// An absent or empty provider always means the CLI's built-in signer. +const KeylessProviderLarkSuite = "larksuite.keyless" + +const SecretSourceTEE = "tee" + // --------------------------------------------------------------------------- // SecretInput — union type: plain string or SecretRef // --------------------------------------------------------------------------- diff --git a/internal/core/types.go b/internal/core/types.go index 29720d4c35..037ad26601 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -63,3 +63,10 @@ func ResolveEndpoints(brand LarkBrand) Endpoints { func ResolveOpenBaseURL(brand LarkBrand) string { return ResolveEndpoints(brand).Open } + +// OpenAPIAudience returns the client_assertion `aud` value for the brand: the +// bare Open API host per the App Authentication JWT spec — "open.feishu.cn" or +// "open.larksuite.com" — not the full token endpoint URL. +func OpenAPIAudience(brand LarkBrand) string { + return strings.TrimPrefix(ResolveOpenBaseURL(brand), "https://") +} diff --git a/internal/core/types_test.go b/internal/core/types_test.go index aa196ff8b3..01fd7246e6 100644 --- a/internal/core/types_test.go +++ b/internal/core/types_test.go @@ -91,3 +91,12 @@ func TestResolveEndpoints_NormalizesBrand(t *testing.T) { t.Errorf("ResolveEndpoints(unexpected).Open = %q, want the feishu default", got) } } + +func TestOpenAPIAudience(t *testing.T) { + if got := OpenAPIAudience(BrandFeishu); got != "open.feishu.cn" { + t.Errorf("OpenAPIAudience(feishu) = %q, want open.feishu.cn", got) + } + if got := OpenAPIAudience(BrandLark); got != "open.larksuite.com" { + t.Errorf("OpenAPIAudience(lark) = %q, want open.larksuite.com", got) + } +} diff --git a/internal/credential/default_provider.go b/internal/credential/default_provider.go index d7401b62ed..b2fe8b278f 100644 --- a/internal/credential/default_provider.go +++ b/internal/credential/default_provider.go @@ -17,6 +17,7 @@ import ( "github.com/larksuite/cli/internal/keychain" extcred "github.com/larksuite/cli/extension/credential" + "github.com/larksuite/cli/internal/keysigner" ) // classifyTATResponseCode wraps a deterministic (non-transient) failure from the @@ -175,6 +176,18 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult, if err != nil { return nil, err } + + // private_key_jwt apps have no app secret: mint via the jwt-bearer grant + // using a TEE-signed client_assertion instead. + if acct.AuthMethod == core.AuthMethodPrivateKeyJWT { + signer := keysigner.Active() + token, err := FetchTATWithAssertionForProvider(ctx, httpClient, acct.Brand, acct.AppID, signer, acct.KeyProvider, acct.KeyLabel) + if err != nil { + return nil, err + } + return &TokenResult{Token: token}, nil + } + token, err := FetchTAT(ctx, httpClient, acct.Brand, acct.AppID, acct.AppSecret) if err != nil { return nil, err diff --git a/internal/credential/tat_fetch.go b/internal/credential/tat_fetch.go index 0d916245d4..758fba6a78 100644 --- a/internal/credential/tat_fetch.go +++ b/internal/credential/tat_fetch.go @@ -12,7 +12,12 @@ import ( "net/url" "strings" + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/keylesshelper" + "github.com/larksuite/cli/internal/keylessprovider" + "github.com/larksuite/cli/internal/keysigner" ) // FetchTAT performs a single HTTP POST to mint a tenant access token via the @@ -100,3 +105,120 @@ func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand } return "", classifyTATResponseCode(result.Code, result.Error, desc, string(brand), appID) } + +// FetchTATWithAssertion mints a tenant access token for a private_key_jwt app via +// the RFC 7523 jwt-bearer grant: it signs a short-lived client_assertion with the +// TEE-held key and posts it to the unified OAuth token endpoint, replacing the +// app_secret entirely. +// +// The unified v2 token endpoint returns the minted token as access_token +// (tenant_access_token is accepted as a fallback). +func FetchTATWithAssertion(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, clientID string, signer keysigner.Signer, keyLabel string) (string, error) { + return FetchTATWithAssertionForProvider(ctx, httpClient, brand, clientID, signer, "", keyLabel) +} + +// FetchTATWithAssertionForProvider routes one app authentication by its +// persisted keyRef.provider. Empty is the stable built-in signer route; +// larksuite.keyless is resolved afresh for this operation; all other values +// fail closed in keylessprovider.Resolve. +func FetchTATWithAssertionForProvider(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, clientID string, signer keysigner.Signer, provider, keyLabel string) (string, error) { + var helper *keylesshelper.Command + var err error + if strings.TrimSpace(provider) != "" { + helper, err = keylessprovider.Resolve(ctx, provider) + if err != nil { + return "", err + } + } + return FetchTATWithAssertionWithHelper(ctx, httpClient, brand, clientID, signer, helper, keyLabel) +} + +// FetchTATWithAssertionWithHelper is the single-resolution variant used when +// the caller must make a preflight decision from the same helper snapshot. +func FetchTATWithAssertionWithHelper(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, clientID string, signer keysigner.Signer, helper *keylesshelper.Command, keyLabel string) (string, error) { + if signer == nil && helper == nil { + return "", errs.NewConfigError(errs.SubtypeInvalidClient, + "profile uses private_key_jwt but no TEE key signer is available on this build"). + WithHint("install a build with the platform key-signer extension, configure an external keyless signer, or reconfigure the app to use an app secret") + } + ep := core.ResolveEndpoints(brand) + endpoint := ep.Open + auth.PathOAuthTokenV2 + + assertionType, assertion, err := auth.SignClientAssertion(ctx, signer, helper, keyLabel, clientID, core.OpenAPIAudience(brand)) + if err != nil { + return "", err + } + + form := url.Values{} + form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") + form.Set("client_id", clientID) + form.Set("client_assertion_type", assertionType) + form.Set("client_assertion", assertion) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("read token response: %w", err) + } + + var result struct { + Code int `json:"code"` + Msg string `json:"msg"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + AccessToken string `json:"access_token"` + TenantAccessToken string `json:"tenant_access_token"` + } + _ = json.Unmarshal(body, &result) // best-effort; error body may not be JSON + + token := result.AccessToken + if token == "" { + token = result.TenantAccessToken + } + if resp.StatusCode == http.StatusOK && token != "" && result.Error == "" && result.Code == 0 { + return token, nil + } + + // Surface the server's reason, preferring the OAuth `error` code (e.g. + // unauthorized_client) which is more diagnostic than the description alone. + detail := result.ErrorDescription + if detail == "" { + detail = result.Msg + } + if detail == "" { + detail = strings.TrimSpace(string(body)) + } + if result.Error != "" { + return "", classifyAssertionError(result.Error, resp.StatusCode, detail) + } + return "", fmt.Errorf("token endpoint HTTP %d (code=%d): %s", resp.StatusCode, result.Code, detail) +} + +// classifyAssertionError maps the OAuth token endpoint's `error` field to a +// typed or untyped error. Only deterministic client-credential rejections get a +// typed errs.ConfigError (so runProbePKJWT can tell "this key is not bound to +// this app" apart from upstream noise); every other error (e.g. +// temporarily_unavailable) stays untyped and is swallowed by the probe. detail +// carries only the server's error_description / msg / body text — it never +// echoes the client_assertion or private key (the assertion lives only in the +// request form). +func classifyAssertionError(oauthError string, httpStatus int, detail string) error { + switch oauthError { + case "invalid_client", "unauthorized_client", "invalid_grant": + return errs.NewConfigError(errs.SubtypeInvalidClient, + "token endpoint rejected the key (%s): %s", oauthError, detail) + default: + return fmt.Errorf("token endpoint HTTP %d (%s): %s", httpStatus, oauthError, detail) + } +} diff --git a/internal/credential/tat_fetch_test.go b/internal/credential/tat_fetch_test.go index a899d205ac..e7ac012cd1 100644 --- a/internal/credential/tat_fetch_test.go +++ b/internal/credential/tat_fetch_test.go @@ -5,15 +5,22 @@ package credential import ( "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" "errors" "io" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/keysigner" ) // stubRoundTripper lets us assert request shape and return canned responses. @@ -307,3 +314,141 @@ func (r *urlRewriteRT) RoundTrip(req *http.Request) (*http.Response, error) { req2.Header = req.Header return http.DefaultTransport.RoundTrip(req2) } + +// fakeTATSigner is a real in-memory ECDSA P-256 signer for assertion tests. +type fakeTATSigner struct{ key *ecdsa.PrivateKey } + +func newFakeTATSigner(t *testing.T) *fakeTATSigner { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + return &fakeTATSigner{key: k} +} + +func (f *fakeTATSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) { + return f.key.Public(), nil +} +func (f *fakeTATSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) { + return f.key.Public(), nil +} +func (f *fakeTATSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) { + h := sha256.Sum256(in) + r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:]) + if err != nil { + return nil, "", err + } + sig := make([]byte, 64) + r.FillBytes(sig[:32]) + s.FillBytes(sig[32:]) + return sig, keysigner.AlgES256, nil +} + +func TestFetchTATWithAssertion_Success(t *testing.T) { + rt := &stubRoundTripper{respCode: 200, respBody: `{"access_token":"test-token","token_type":"Bearer","expires_in":7200}`} + hc := &http.Client{Transport: rt} + + token, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "agent-key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token != "test-token" { + t.Errorf("token = %q, want test-token", token) + } + if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" { + t.Errorf("url = %s", rt.gotReq.URL.String()) + } + + form, err := url.ParseQuery(rt.gotBody) + if err != nil { + t.Fatal(err) + } + if form.Get("grant_type") != "urn:ietf:params:oauth:grant-type:jwt-bearer" { + t.Errorf("grant_type = %q", form.Get("grant_type")) + } + if form.Get("client_assertion_type") != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" { + t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type")) + } + if form.Get("client_assertion") == "" { + t.Error("client_assertion is empty") + } + if form.Has("client_secret") { + t.Error("client_secret must NOT be sent for private_key_jwt") + } + if form.Get("client_id") != "cli_app" { + t.Errorf("client_id = %q", form.Get("client_id")) + } +} + +func TestFetchTATWithAssertion_NilSigner(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + hc := &http.Client{Transport: &stubRoundTripper{respCode: 200, respBody: `{}`}} + if _, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", nil, "k"); err == nil { + t.Fatal("expected error when signer is nil") + } +} + +func TestFetchTATWithAssertion_ServerError(t *testing.T) { + rt := &stubRoundTripper{respCode: 200, respBody: `{"error":"invalid_client","error_description":"unknown key"}`} + hc := &http.Client{Transport: rt} + if _, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k"); err == nil { + t.Fatal("expected error for invalid_client response") + } +} + +func TestFetchTATWithAssertion_LimitsErrorBody(t *testing.T) { + rt := &stubRoundTripper{respCode: 502, respBody: strings.Repeat("x", 2<<20)} + hc := &http.Client{Transport: rt} + + _, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k") + if err == nil { + t.Fatal("expected error") + } + if len(err.Error()) > (1<<20)+512 { + t.Fatalf("error length = %d, want bounded", len(err.Error())) + } +} + +// Deterministic OAuth client rejections must be typed (ConfigError / +// SubtypeInvalidClient) so runProbePKJWT can tell "the key is not bound to this +// app" apart from transport noise. +func TestFetchTATWithAssertion_DeterministicReject_Typed(t *testing.T) { + for _, oauthErr := range []string{"invalid_client", "unauthorized_client", "invalid_grant"} { + rt := &stubRoundTripper{respCode: 401, respBody: `{"error":"` + oauthErr + `","error_description":"bad key"}`} + hc := &http.Client{Transport: rt} + _, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k") + if err == nil { + t.Fatalf("%s: expected error", oauthErr) + } + if !errs.IsTyped(err) { + t.Errorf("%s: must be typed, got %T", oauthErr, err) + } + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) || cfgErr.Subtype != errs.SubtypeInvalidClient { + t.Errorf("%s: want ConfigError/InvalidClient, got %T %v", oauthErr, err, err) + } + } +} + +// Unrecognized OAuth errors and non-payload noise stay UNTYPED so the probe +// treats them as upstream noise and stays silent. +func TestFetchTATWithAssertion_AmbiguousError_Untyped(t *testing.T) { + cases := []string{ + `{"error":"temporarily_unavailable","error_description":"retry"}`, + `{"code":99999,"msg":"weird"}`, + `not json`, + } + for _, body := range cases { + rt := &stubRoundTripper{respCode: 503, respBody: body} + hc := &http.Client{Transport: rt} + _, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k") + if err == nil { + t.Fatalf("body %q: expected error", body) + } + if errs.IsTyped(err) { + t.Errorf("body %q: must be UNTYPED, got typed %T", body, err) + } + } +} diff --git a/internal/credential/types.go b/internal/credential/types.go index 430b22ba87..593be0daa5 100644 --- a/internal/credential/types.go +++ b/internal/credential/types.go @@ -26,6 +26,9 @@ type Account struct { UserName string Lang i18n.Lang SupportedIdentities uint8 + AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT + KeyLabel string // resolved TEE key handle for private_key_jwt + KeyProvider string // empty == built-in signer; explicit external provider otherwise } const runtimePlaceholderAppSecret = "__LARKSUITE_CLI_TOKEN_ONLY__" @@ -69,6 +72,9 @@ func AccountFromCliConfig(cfg *core.CliConfig) *Account { UserName: cfg.UserName, Lang: cfg.Lang, SupportedIdentities: cfg.SupportedIdentities, + AuthMethod: cfg.AuthMethod, + KeyLabel: cfg.KeyLabel, + KeyProvider: cfg.KeyProvider, } } @@ -88,6 +94,9 @@ func (a *Account) ToCliConfig() *core.CliConfig { UserName: a.UserName, Lang: a.Lang, SupportedIdentities: a.SupportedIdentities, + AuthMethod: a.AuthMethod, + KeyLabel: a.KeyLabel, + KeyProvider: a.KeyProvider, } } diff --git a/internal/credential/types_test.go b/internal/credential/types_test.go index 1c1f288534..fc383576ad 100644 --- a/internal/credential/types_test.go +++ b/internal/credential/types_test.go @@ -56,13 +56,16 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) { UserName: "alice", Lang: i18n.LangJaJP, SupportedIdentities: 3, + AuthMethod: core.AuthMethodPrivateKeyJWT, + KeyLabel: "openclaw-lark", + KeyProvider: core.KeylessProviderLarkSuite, } acct := AccountFromCliConfig(cfg) if acct == nil { t.Fatal("AccountFromCliConfig() = nil") } - if acct.AppID != cfg.AppID || acct.ProfileName != cfg.ProfileName || acct.UserName != cfg.UserName { + if acct.AppID != cfg.AppID || acct.ProfileName != cfg.ProfileName || acct.UserName != cfg.UserName || acct.KeyProvider != cfg.KeyProvider { t.Fatalf("AccountFromCliConfig() = %#v, want copied fields from %#v", acct, cfg) } if acct.Lang != cfg.Lang { @@ -73,7 +76,7 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) { if roundtrip == nil { t.Fatal("ToCliConfig() = nil") } - if roundtrip.AppID != cfg.AppID || roundtrip.ProfileName != cfg.ProfileName || roundtrip.UserName != cfg.UserName { + if roundtrip.AppID != cfg.AppID || roundtrip.ProfileName != cfg.ProfileName || roundtrip.UserName != cfg.UserName || roundtrip.KeyProvider != cfg.KeyProvider { t.Fatalf("ToCliConfig() = %#v, want copied fields from %#v", roundtrip, cfg) } if roundtrip.Lang != cfg.Lang { diff --git a/internal/identitydiag/diagnostics.go b/internal/identitydiag/diagnostics.go index 0e7fb1cc96..374e2e37c3 100644 --- a/internal/identitydiag/diagnostics.go +++ b/internal/identitydiag/diagnostics.go @@ -202,7 +202,9 @@ func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, v Hint: "check strict mode or the active credential provider", } } - if cfg.SupportedIdentities == 0 && !credential.HasRealAppSecret(cfg.AppSecret) { + // private_key_jwt apps have no app secret — the bot/tenant token is minted via + // a TEE-signed client_assertion — so absence of a secret is NOT "unconfigured". + if cfg.SupportedIdentities == 0 && !credential.HasRealAppSecret(cfg.AppSecret) && cfg.AuthMethod != core.AuthMethodPrivateKeyJWT { return Identity{ Status: StatusNotConfigured, Message: "Bot identity: not configured (missing app secret or bot token)", diff --git a/internal/keylesshelper/helper.go b/internal/keylesshelper/helper.go new file mode 100644 index 0000000000..846b0458e6 --- /dev/null +++ b/internal/keylesshelper/helper.go @@ -0,0 +1,287 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package keylesshelper invokes a signer generation that has already been +// resolved and verified by internal/keylessprovider. +package keylesshelper + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" + + "github.com/larksuite/cli/internal/keysigner" + "github.com/larksuite/cli/internal/vfs" +) + +const ( + helperOutputLimit = 1 << 20 + helperStderrLimit = 64 << 10 + helperExecutionTimeout = 10 * time.Second +) + +type request struct { + Op string `json:"op"` + KeyRef string `json:"keyRef,omitempty"` + Nonce string `json:"nonce,omitempty"` + ClientID string `json:"clientId,omitempty"` + Audience string `json:"aud,omitempty"` +} + +type response struct { + OK bool `json:"ok"` + Error *protocolError `json:"error,omitempty"` + Attestation string `json:"attestation,omitempty"` + ClientAssertionType string `json:"client_assertion_type,omitempty"` + ClientAssertion string `json:"client_assertion,omitempty"` +} + +type protocolError struct { + Type string `json:"type"` + Message string `json:"message"` +} + +// Command is one verified provider executable. It is intentionally impossible +// to construct from an app-config path, argv, or environment variable. +type Command struct { + argv []string + providerCWD string + providerHome string + providerSHA string +} + +// NewProviderCommand builds the fixed empty-argv command used by a verified +// provider executable. Provider execution never accepts argv from app config or +// the environment. +func NewProviderCommand(binaryPath, providerRoot, signerHome, expectedSHA256 string) (*Command, error) { + if strings.TrimSpace(binaryPath) == "" || strings.TrimSpace(providerRoot) == "" { + return nil, fmt.Errorf("provider binary path and root must be non-empty") + } + if len(expectedSHA256) != 64 { + return nil, fmt.Errorf("provider binary digest must be a SHA-256 hex string") + } + return &Command{argv: []string{binaryPath}, providerCWD: providerRoot, providerHome: signerHome, providerSHA: expectedSHA256}, nil +} + +// Probe asks this resolved helper for its public key. +func (c *Command) Probe(ctx context.Context, keyRef string) error { + resp, err := c.execute(ctx, request{ + Op: "pubkey", + KeyRef: defaultKeyRef(keyRef), + }) + if err != nil { + return err + } + return validateResponse(resp) +} + +// SignAttestation asks this resolved helper to mint a registration attestation JWT. +func (c *Command) SignAttestation(ctx context.Context, keyRef, nonce string) (string, error) { + resp, err := c.execute(ctx, request{ + Op: "sign-attestation", + KeyRef: defaultKeyRef(keyRef), + Nonce: nonce, + }) + if err != nil { + return "", err + } + if err := validateResponse(resp); err != nil { + return "", err + } + if resp.Attestation == "" { + return "", fmt.Errorf("keyless helper returned empty attestation") + } + return resp.Attestation, nil +} + +// SignClientAssertion asks this resolved helper to mint a token-endpoint client_assertion. +func (c *Command) SignClientAssertion(ctx context.Context, keyRef, clientID, audience string) (string, string, error) { + resp, err := c.execute(ctx, request{ + Op: "sign-assertion", + KeyRef: defaultKeyRef(keyRef), + ClientID: clientID, + Audience: audience, + }) + if err != nil { + return "", "", err + } + if err := validateResponse(resp); err != nil { + return "", "", err + } + if resp.ClientAssertionType == "" { + return "", "", fmt.Errorf("keyless helper returned empty client_assertion_type") + } + if resp.ClientAssertion == "" { + return "", "", fmt.Errorf("keyless helper returned empty client_assertion") + } + return resp.ClientAssertionType, resp.ClientAssertion, nil +} + +func (c *Command) execute(ctx context.Context, req request) (response, error) { + if err := verifyProviderBinary(c.argv[0], c.providerSHA); err != nil { + return response{}, err + } + return runCommandConfigured(ctx, c.argv, req, c.providerCWD, providerEnvironment(c.providerHome)) +} + +func verifyProviderBinary(path, expectedSHA string) error { + info, err := vfs.Lstat(path) + if err != nil { + return fmt.Errorf("recheck provider signer: %w", err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() <= 0 || info.Size() > 512<<20 { + return fmt.Errorf("provider signer changed before execution") + } + f, err := vfs.Open(path) + if err != nil { + return fmt.Errorf("reopen provider signer: %w", err) + } + defer f.Close() + h := sha256.New() + n, err := io.Copy(h, io.LimitReader(f, 512<<20+1)) + if err != nil || n != info.Size() { + return fmt.Errorf("rehash provider signer: file changed while reading") + } + if hex.EncodeToString(h.Sum(nil)) != expectedSHA { + return fmt.Errorf("provider signer digest changed before execution") + } + return nil +} + +func validateResponse(resp response) error { + if resp.Error != nil { + return fmt.Errorf("keyless helper %s: %s", resp.Error.Type, resp.Error.Message) + } + if !resp.OK { + return fmt.Errorf("keyless helper returned ok=false") + } + return nil +} + +func defaultKeyRef(keyRef string) string { + if keyRef != "" { + return keyRef + } + return keysigner.DefaultKeyLabel +} + +func runCommand(ctx context.Context, argv []string, req request) (response, error) { + return runCommandConfigured(ctx, argv, req, "", nil) +} + +func runCommandConfigured(ctx context.Context, argv []string, req request, cwd string, env []string) (response, error) { + body, err := json.Marshal(req) + if err != nil { + return response{}, fmt.Errorf("marshal keyless helper request: %w", err) + } + body = append(body, '\n') + + helperCtx, cancel := withExecutionTimeout(ctx) + defer cancel() + + // CommandContext's default cancellation kills the helper process. This is + // important for unattended agent calls: a signer blocked on platform UI must + // not hold the caller indefinitely. + cmd := exec.CommandContext(helperCtx, argv[0], argv[1:]...) + if cwd != "" { + cmd.Dir = cwd + cmd.Env = env + } + cmd.Stdin = bytes.NewReader(body) + stdout := &limitedBuffer{limit: helperOutputLimit} + stderr := &limitedBuffer{limit: helperStderrLimit} + cmd.Stdout = stdout + cmd.Stderr = stderr + + runErr := cmd.Run() + if err := helperCtx.Err(); err != nil { + // Never parse or include helper output on cancellation. A partially written + // response may contain a client assertion or other credential material. + if errors.Is(err, context.DeadlineExceeded) { + return response{}, fmt.Errorf("keyless helper timed out: %w", context.DeadlineExceeded) + } + return response{}, fmt.Errorf("keyless helper canceled: %w", err) + } + var resp response + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + if runErr != nil { + return response{}, helperRunError(runErr, stderr.String()) + } + return response{}, fmt.Errorf("keyless helper produced invalid JSON: %w", err) + } + if runErr != nil && resp.Error == nil { + return response{}, helperRunError(runErr, stderr.String()) + } + return resp, nil +} + +func providerEnvironment(homeOverride string) []string { + // Signer implementations use OS facilities and must not inherit language + // runtime/proxy/library injection variables. HOME/TMPDIR/SystemRoot are the + // minimal cross-platform values currently needed by supported backends. + keep := map[string]bool{"HOME": true, "TMPDIR": true, "TEMP": true, "TMP": true, "SystemRoot": true, "WINDIR": true} + var env []string + for _, entry := range os.Environ() { + name := entry + if idx := strings.IndexByte(entry, '='); idx >= 0 { + name = entry[:idx] + } + if keep[name] && !(name == "HOME" && homeOverride != "") { + env = append(env, entry) + } + } + if homeOverride != "" { + env = append(env, "HOME="+homeOverride) + } + return env +} + +func withExecutionTimeout(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(ctx, helperExecutionTimeout) +} + +func helperRunError(runErr error, stderr string) error { + if errors.Is(runErr, os.ErrNotExist) { + return fmt.Errorf("keyless helper executable no longer exists; repair or reinstall the OpenClaw Feishu plugin: %w", runErr) + } + if strings.TrimSpace(stderr) != "" { + return fmt.Errorf("keyless helper failed: %w (stderr omitted)", runErr) + } + return fmt.Errorf("keyless helper failed: %w", runErr) +} + +type limitedBuffer struct { + buf bytes.Buffer + limit int +} + +func (b *limitedBuffer) Write(p []byte) (int, error) { + if b.limit <= 0 { + return len(p), nil + } + remaining := b.limit - b.buf.Len() + if remaining > 0 { + if len(p) < remaining { + remaining = len(p) + } + _, _ = b.buf.Write(p[:remaining]) + } + return len(p), nil +} + +func (b *limitedBuffer) Bytes() []byte { + return b.buf.Bytes() +} + +func (b *limitedBuffer) String() string { + return b.buf.String() +} diff --git a/internal/keylesshelper/helper_test.go b/internal/keylesshelper/helper_test.go new file mode 100644 index 0000000000..ecb6cfdc8b --- /dev/null +++ b/internal/keylesshelper/helper_test.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package keylesshelper + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +const helperProcessMode = "GO_WANT_KEYLESS_PROVIDER_HELPER" + +func TestRunCommandProtocol(t *testing.T) { + t.Setenv(helperProcessMode, "reply") + resp, err := runCommand(context.Background(), []string{os.Args[0], "-test.run=^TestHelperProcess$"}, request{ + Op: "sign-assertion", KeyRef: "key-1", ClientID: "cli_a", Audience: "aud", + }) + if err != nil { + t.Fatal(err) + } + if !resp.OK || resp.ClientAssertion != "helper.jwt" { + t.Fatalf("response = %#v", resp) + } +} + +func TestRunCommandTimeoutDoesNotLeakOutput(t *testing.T) { + t.Setenv(helperProcessMode, "hang") + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + _, err := runCommand(ctx, []string{os.Args[0], "-test.run=^TestHelperProcess$"}, request{Op: "pubkey", KeyRef: "key-1"}) + if !errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "secret.jwt") { + t.Fatalf("error = %v", err) + } +} + +func TestProviderCommandRechecksDigestBeforeSpawn(t *testing.T) { + root := t.TempDir() + binary := filepath.Join(root, "signer") + if err := os.WriteFile(binary, []byte("first"), 0700); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256([]byte("first")) + command, err := NewProviderCommand(binary, root, "", hex.EncodeToString(digest[:])) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(binary, []byte("second"), 0700); err != nil { + t.Fatal(err) + } + if err := command.Probe(context.Background(), "key-1"); err == nil || !strings.Contains(err.Error(), "digest changed") { + t.Fatalf("Probe error = %v", err) + } +} + +func TestProviderEnvironmentOverridesOnlyPinnedHome(t *testing.T) { + t.Setenv("HOME", "/original") + t.Setenv("HTTPS_PROXY", "http://untrusted.invalid") + env := providerEnvironment("/isolated") + joined := strings.Join(env, "\n") + if !strings.Contains(joined, "HOME=/isolated") || strings.Contains(joined, "HOME=/original") || strings.Contains(joined, "HTTPS_PROXY") { + t.Fatalf("provider environment = %q", joined) + } +} + +func TestHelperProcess(t *testing.T) { + switch os.Getenv(helperProcessMode) { + case "reply": + var req request + if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil { + os.Exit(2) + } + _ = json.NewEncoder(os.Stdout).Encode(response{ + OK: true, ClientAssertionType: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", ClientAssertion: "helper.jwt", + }) + os.Exit(0) + case "hang": + _, _ = io.WriteString(os.Stdout, `{"ok":true,"client_assertion":"secret.jwt"`) + for { + time.Sleep(time.Hour) + } + } +} diff --git a/internal/keylessprovider/inspect_command_unix.go b/internal/keylessprovider/inspect_command_unix.go new file mode 100644 index 0000000000..80caea7ca1 --- /dev/null +++ b/internal/keylessprovider/inspect_command_unix.go @@ -0,0 +1,17 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build darwin || linux + +package keylessprovider + +import ( + "context" + "os/exec" +) + +func newOpenClawInspectCommand(ctx context.Context, executable string, env []string) (*exec.Cmd, error) { + cmd := exec.CommandContext(ctx, executable, "plugins", "inspect", pluginID, "--json") + cmd.Env = env + return cmd, nil +} diff --git a/internal/keylessprovider/inspect_command_windows.go b/internal/keylessprovider/inspect_command_windows.go new file mode 100644 index 0000000000..6488039430 --- /dev/null +++ b/internal/keylessprovider/inspect_command_windows.go @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build windows + +package keylessprovider + +import ( + "context" + "fmt" + "os/exec" + "path/filepath" + "strings" + "syscall" + + "golang.org/x/sys/windows" +) + +const inspectLauncherEnv = "LARK_CLI_OPENCLAW_INSPECT_LAUNCHER" + +// newOpenClawInspectCommand runs native launchers directly and npm's .cmd/.bat +// launchers through the OS-owned command interpreter. The launcher path travels +// in a dedicated environment variable and is expanded once inside a quoted +// command, while delayed expansion is disabled. This avoids interpolating a +// user-controlled path into cmd.exe syntax. +func newOpenClawInspectCommand(ctx context.Context, executable string, env []string) (*exec.Cmd, error) { + ext := strings.ToLower(filepath.Ext(executable)) + if ext == ".exe" || ext == ".com" { + cmd := exec.CommandContext(ctx, executable, "plugins", "inspect", pluginID, "--json") + cmd.Env = env + return cmd, nil + } + if ext != ".cmd" && ext != ".bat" { + return nil, fmt.Errorf("unsupported Windows OpenClaw launcher extension %q", ext) + } + + systemDir, err := windows.GetSystemDirectory() + if err != nil { + return nil, fmt.Errorf("resolve Windows system directory: %w", err) + } + commandInterpreter := filepath.Join(systemDir, "cmd.exe") + cmd := exec.CommandContext(ctx, commandInterpreter) + cmd.Args = nil + cmd.Env = append(append([]string(nil), env...), inspectLauncherEnv+"="+executable) + cmd.SysProcAttr = &syscall.SysProcAttr{ + CmdLine: `/d /q /s /v:off /c ""%` + inspectLauncherEnv + `%" plugins inspect ` + pluginID + ` --json"`, + } + return cmd, nil +} diff --git a/internal/keylessprovider/inspect_command_windows_test.go b/internal/keylessprovider/inspect_command_windows_test.go new file mode 100644 index 0000000000..2c5fa3a03d --- /dev/null +++ b/internal/keylessprovider/inspect_command_windows_test.go @@ -0,0 +1,54 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build windows + +package keylessprovider + +import ( + "context" + "path/filepath" + "strings" + "testing" +) + +func TestNewOpenClawInspectCommand_WindowsBatchUsesSystemCmdWithoutPathInterpolation(t *testing.T) { + launcher := `C:\Users\A B&(team)%literal%\openclaw.cmd` + cmd, err := newOpenClawInspectCommand(context.Background(), launcher, []string{"PATH=C:\\Windows\\System32"}) + if err != nil { + t.Fatal(err) + } + if !strings.EqualFold(filepath.Base(cmd.Path), "cmd.exe") { + t.Fatalf("command path = %q, want System32 cmd.exe", cmd.Path) + } + if cmd.SysProcAttr == nil || !strings.Contains(cmd.SysProcAttr.CmdLine, "/d /q /s /v:off /c") { + t.Fatalf("cmd line = %#v", cmd.SysProcAttr) + } + if strings.Contains(cmd.SysProcAttr.CmdLine, launcher) { + t.Fatalf("launcher path was interpolated into cmd syntax: %q", cmd.SysProcAttr.CmdLine) + } + if !strings.Contains(strings.Join(cmd.Env, "\n"), inspectLauncherEnv+"="+launcher) { + t.Fatalf("launcher environment missing: %q", cmd.Env) + } +} + +func TestNewOpenClawInspectCommand_WindowsNativeExecutableIsDirect(t *testing.T) { + launcher := `C:\Program Files\OpenClaw\openclaw.exe` + cmd, err := newOpenClawInspectCommand(context.Background(), launcher, []string{"TEMP=C:\\Temp"}) + if err != nil { + t.Fatal(err) + } + if cmd.Path != launcher || cmd.SysProcAttr != nil { + t.Fatalf("native command = path %q sys %#v", cmd.Path, cmd.SysProcAttr) + } + wantArgs := []string{launcher, "plugins", "inspect", pluginID, "--json"} + if strings.Join(cmd.Args, "\x00") != strings.Join(wantArgs, "\x00") { + t.Fatalf("args = %#v, want %#v", cmd.Args, wantArgs) + } +} + +func TestNewOpenClawInspectCommand_WindowsRejectsUnknownLauncher(t *testing.T) { + if _, err := newOpenClawInspectCommand(context.Background(), `C:\\OpenClaw\\openclaw.ps1`, nil); err == nil { + t.Fatal("PowerShell launcher unexpectedly accepted") + } +} diff --git a/internal/keylessprovider/manifest.go b/internal/keylessprovider/manifest.go new file mode 100644 index 0000000000..526380129c --- /dev/null +++ b/internal/keylessprovider/manifest.go @@ -0,0 +1,155 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package keylessprovider + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/internal/vfs" +) + +const ( + providerManifestFormatVersion = 1 + providerManifestFileName = "signing-providers.json" +) + +type providerManifest struct { + Version int `json:"version"` + Providers map[string]providerManifestEntry `json:"providers"` +} + +type providerManifestEntry struct { + PluginID string `json:"pluginId"` + PluginPackage string `json:"pluginPackage"` + SignerPackage string `json:"signerPackage"` + StateDir string `json:"stateDir"` + PluginDir string `json:"pluginDir"` + PackageDir string `json:"packageDir"` + BinaryPath string `json:"binaryPath"` + PackageVersion string `json:"packageVersion"` + OS string `json:"os"` + Arch string `json:"arch"` + SHA256 string `json:"sha256"` + PackageSize int64 `json:"packageSize"` + PackageModTimeNS int64 `json:"packageModTimeNs"` + BinarySize int64 `json:"binarySize"` + BinaryModTimeNS int64 `json:"binaryModTimeNs"` +} + +var providerManifestMu sync.Mutex + +func providerManifestPath() string { + return filepath.Join(core.GetBaseConfigDir(), providerManifestFileName) +} + +// resolveFromProviderManifest uses the global manifest only as a location +// index. resolvePackage revalidates the full path, ownership, permissions, +// package metadata, and binary digest before a cached location can be used. +func resolveFromProviderManifest(stateDir, goos, goarch string) (resolvedProvider, bool) { + manifest, err := readProviderManifest() + if err != nil || manifest.Version != providerManifestFormatVersion { + return resolvedProvider{}, false + } + entry, ok := manifest.Providers[ProviderID] + if !ok || entry.PluginID != pluginID || entry.PluginPackage != pluginPackageName || + entry.StateDir != stateDir || entry.OS != goos || entry.Arch != goarch { + return resolvedProvider{}, false + } + spec, err := signerPackageFor(goos, goarch) + if err != nil { + return resolvedProvider{}, false + } + if !allowedPackageLocation(entry.PluginDir, entry.PackageDir, spec, goos) { + return resolvedProvider{}, false + } + resolved, err := resolvePackage(stateDir, entry.PluginDir, entry.PackageDir, spec, goos) + if err != nil { + return resolvedProvider{}, false + } + if entry.SignerPackage != spec.name || + !samePath(resolved.binaryPath, entry.BinaryPath, goos) || + resolved.packageVersion != entry.PackageVersion || + resolved.digest != entry.SHA256 || + resolved.packageSize != entry.PackageSize || + resolved.packageModTimeNS != entry.PackageModTimeNS || + resolved.binarySize != entry.BinarySize || + resolved.binaryModTimeNS != entry.BinaryModTimeNS { + return resolvedProvider{}, false + } + return resolved, true +} + +func readProviderManifest() (providerManifest, error) { + path := providerManifestPath() + if err := validateProviderObject(path, false); err != nil { + return providerManifest{}, err + } + data, err := readMetadata(path) + if err != nil { + return providerManifest{}, err + } + var manifest providerManifest + if err := decodeJSONObject(data, &manifest); err != nil { + return providerManifest{}, err + } + if manifest.Providers == nil { + manifest.Providers = make(map[string]providerManifestEntry) + } + return manifest, nil +} + +func saveProviderManifest(resolved resolvedProvider, goos, goarch string) error { + providerManifestMu.Lock() + defer providerManifestMu.Unlock() + spec, err := signerPackageFor(goos, goarch) + if err != nil { + return err + } + + baseDir := core.GetBaseConfigDir() + if err := vfs.MkdirAll(baseDir, 0700); err != nil { + return err + } + if err := validateProviderObject(baseDir, true); err != nil { + return err + } + + manifest, err := readProviderManifest() + if err != nil || manifest.Version != providerManifestFormatVersion { + manifest = providerManifest{ + Version: providerManifestFormatVersion, + Providers: make(map[string]providerManifestEntry), + } + } + if manifest.Providers == nil { + manifest.Providers = make(map[string]providerManifestEntry) + } + manifest.Providers[ProviderID] = providerManifestEntry{ + PluginID: pluginID, + PluginPackage: pluginPackageName, + SignerPackage: spec.name, + StateDir: resolved.stateDir, + PluginDir: resolved.pluginDir, + PackageDir: resolved.packageDir, + BinaryPath: resolved.binaryPath, + PackageVersion: resolved.packageVersion, + OS: goos, + Arch: goarch, + SHA256: resolved.digest, + PackageSize: resolved.packageSize, + PackageModTimeNS: resolved.packageModTimeNS, + BinarySize: resolved.binarySize, + BinaryModTimeNS: resolved.binaryModTimeNS, + } + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + return validate.AtomicWrite(providerManifestPath(), append(data, '\n'), os.FileMode(0600)) +} diff --git a/internal/keylessprovider/manifest_test.go b/internal/keylessprovider/manifest_test.go new file mode 100644 index 0000000000..c23f7fd793 --- /dev/null +++ b/internal/keylessprovider/manifest_test.go @@ -0,0 +1,493 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package keylessprovider + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "sync" + "sync/atomic" + "testing" +) + +func TestResolve_ManifestMissPersistsAndHitSkipsInspect(t *testing.T) { + configDir := useIsolatedProviderManifest(t) + fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true) + t.Setenv(openClawStateDirEnv, fx.stateDir) + data := marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir)) + + var calls atomic.Int32 + stubInspectFunction(t, func(context.Context, string) ([]byte, error) { + calls.Add(1) + return data, nil + }) + if command, err := Resolve(context.Background(), ProviderID); err != nil || command == nil { + t.Fatalf("first Resolve = %v, %v", command, err) + } + if calls.Load() != 1 { + t.Fatalf("inspect calls after miss = %d, want 1", calls.Load()) + } + + path := filepath.Join(configDir, providerManifestFileName) + manifestData, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var manifest providerManifest + if err := json.Unmarshal(manifestData, &manifest); err != nil { + t.Fatalf("manifest is not valid JSON: %v", err) + } + entry := manifest.Providers[ProviderID] + if manifest.Version != providerManifestFormatVersion || entry.PackageDir != fx.packageDir || + entry.BinaryPath != fx.binary || entry.SHA256 != fx.binaryDigest || entry.PackageVersion != "1.2.3" { + t.Fatalf("persisted manifest = %#v", manifest) + } + if runtime.GOOS != "windows" { + if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("manifest permissions = %v, %v", info, err) + } + } + + if command, err := Resolve(context.Background(), ProviderID); err != nil || command == nil { + t.Fatalf("cached Resolve = %v, %v", command, err) + } + if calls.Load() != 1 { + t.Fatalf("manifest hit restarted inspect; calls = %d", calls.Load()) + } +} + +func TestResolve_ManifestMutationRefreshes(t *testing.T) { + useIsolatedProviderManifest(t) + fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true) + t.Setenv(openClawStateDirEnv, fx.stateDir) + data := marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir)) + + var calls atomic.Int32 + stubInspectFunction(t, func(context.Context, string) ([]byte, error) { + calls.Add(1) + return data, nil + }) + if _, err := Resolve(context.Background(), ProviderID); err != nil { + t.Fatal(err) + } + + changed := []byte("TEST optional-package signer") // same length, different digest + if info, err := os.Stat(fx.binary); err != nil || info.Size() != int64(len(changed)) { + t.Fatalf("fixture length precondition failed: %v, %v", info, err) + } + if err := os.WriteFile(fx.binary, changed, 0700); err != nil { + t.Fatal(err) + } + if _, err := Resolve(context.Background(), ProviderID); err != nil { + t.Fatal(err) + } + if calls.Load() != 2 { + t.Fatalf("inspect calls after binary mutation = %d, want 2", calls.Load()) + } + + manifest, err := readProviderManifest() + if err != nil { + t.Fatal(err) + } + entry := manifest.Providers[ProviderID] + if entry.SHA256 == fx.binaryDigest { + t.Fatal("manifest retained the stale signer digest") + } +} + +func TestRefresh_BypassesValidManifest(t *testing.T) { + useIsolatedProviderManifest(t) + oldFx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true) + newFx := newManagedPackageFixtureInState(t, oldFx.stateDir, runtime.GOOS, runtime.GOARCH, true) + t.Setenv(openClawStateDirEnv, oldFx.stateDir) + + var current = oldFx + var calls atomic.Int32 + stubInspectFunction(t, func(context.Context, string) ([]byte, error) { + calls.Add(1) + return marshalInspectDocument(t, inspectPluginDocument(current, pluginID, current.packageDir)), nil + }) + if _, err := Resolve(context.Background(), ProviderID); err != nil { + t.Fatal(err) + } + current = newFx + if _, err := Resolve(context.Background(), ProviderID); err != nil { + t.Fatal(err) + } + if calls.Load() != 1 { + t.Fatalf("normal Resolve should retain valid binding; calls = %d", calls.Load()) + } + if _, err := Refresh(context.Background(), ProviderID); err != nil { + t.Fatal(err) + } + if calls.Load() != 2 { + t.Fatalf("Refresh did not inspect; calls = %d", calls.Load()) + } + manifest, err := readProviderManifest() + if err != nil { + t.Fatal(err) + } + if got := manifest.Providers[ProviderID].PackageDir; got != newFx.packageDir { + t.Fatalf("refreshed packageDir = %q, want %q", got, newFx.packageDir) + } +} + +func TestPrepareRefresh_AbandonedCommitPreservesManifestBytes(t *testing.T) { + configDir := useIsolatedProviderManifest(t) + oldFx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true) + newFx := newManagedPackageFixtureInState(t, oldFx.stateDir, runtime.GOOS, runtime.GOARCH, true) + t.Setenv(openClawStateDirEnv, oldFx.stateDir) + + current := oldFx + stubInspectFunction(t, func(context.Context, string) ([]byte, error) { + return marshalInspectDocument(t, inspectPluginDocument(current, pluginID, current.packageDir)), nil + }) + if _, err := Resolve(context.Background(), ProviderID); err != nil { + t.Fatal(err) + } + manifestPath := filepath.Join(configDir, providerManifestFileName) + before, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + + current = newFx + command, commit, err := PrepareRefresh(context.Background(), ProviderID) + if err != nil || command == nil || commit == nil { + t.Fatalf("PrepareRefresh = %v, commit %v, %v", command, commit != nil, err) + } + afterPrepare, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(afterPrepare, before) { + t.Fatal("PrepareRefresh changed the manifest before its commit callback ran") + } + + const committers = 8 + errs := make(chan error, committers) + var wg sync.WaitGroup + for i := 0; i < committers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + errs <- commit() + }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + manifest, err := readProviderManifest() + if err != nil { + t.Fatal(err) + } + if got := manifest.Providers[ProviderID].PackageDir; got != newFx.packageDir { + t.Fatalf("committed packageDir = %q, want %q", got, newFx.packageDir) + } +} + +func TestPrepareRefresh_CommitReturnsFirstErrorWithoutRetry(t *testing.T) { + configDir := useIsolatedProviderManifest(t) + fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true) + t.Setenv(openClawStateDirEnv, fx.stateDir) + stubInspectFunction(t, func(context.Context, string) ([]byte, error) { + return marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir)), nil + }) + _, commit, err := PrepareRefresh(context.Background(), ProviderID) + if err != nil || commit == nil { + t.Fatalf("PrepareRefresh commit %v, error %v", commit != nil, err) + } + + if err := os.RemoveAll(configDir); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configDir, []byte("block manifest directory"), 0600); err != nil { + t.Fatal(err) + } + firstErr := commit() + if firstErr == nil { + t.Fatal("first commit unexpectedly succeeded") + } + if err := os.Remove(configDir); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(configDir, 0700); err != nil { + t.Fatal(err) + } + secondErr := commit() + if secondErr != firstErr { + t.Fatalf("second commit error = %v; want cached first error %v", secondErr, firstErr) + } + if _, err := os.Stat(filepath.Join(configDir, providerManifestFileName)); !os.IsNotExist(err) { + t.Fatalf("second commit retried manifest write: %v", err) + } +} + +func TestPrepareRefresh_CommitRejectsSameStampBinaryMutation(t *testing.T) { + configDir := useIsolatedProviderManifest(t) + oldFx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true) + newFx := newManagedPackageFixtureInState(t, oldFx.stateDir, runtime.GOOS, runtime.GOARCH, true) + t.Setenv(openClawStateDirEnv, oldFx.stateDir) + + current := oldFx + stubInspectFunction(t, func(context.Context, string) ([]byte, error) { + return marshalInspectDocument(t, inspectPluginDocument(current, pluginID, current.packageDir)), nil + }) + if _, err := Resolve(context.Background(), ProviderID); err != nil { + t.Fatal(err) + } + manifestPath := filepath.Join(configDir, providerManifestFileName) + before, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + + current = newFx + _, commit, err := PrepareRefresh(context.Background(), ProviderID) + if err != nil || commit == nil { + t.Fatalf("PrepareRefresh commit %v, error %v", commit != nil, err) + } + info, err := os.Stat(newFx.binary) + if err != nil { + t.Fatal(err) + } + changed := []byte("TEST optional-package signer") + if info.Size() != int64(len(changed)) { + t.Fatalf("fixture size = %d, mutated size = %d", info.Size(), len(changed)) + } + if err := os.WriteFile(newFx.binary, changed, 0700); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(newFx.binary, info.ModTime(), info.ModTime()); err != nil { + t.Fatal(err) + } + mutatedInfo, err := os.Stat(newFx.binary) + if err != nil { + t.Fatal(err) + } + if mutatedInfo.Size() != info.Size() || !mutatedInfo.ModTime().Equal(info.ModTime()) { + t.Fatalf("mutation did not preserve size/mtime: before %d/%v, after %d/%v", + info.Size(), info.ModTime(), mutatedInfo.Size(), mutatedInfo.ModTime()) + } + + if err := commit(); err == nil { + t.Fatal("commit accepted a signer binary changed after PrepareRefresh") + } + after, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(after, before) { + t.Fatal("failed commit changed the previous manifest") + } +} + +func TestRefresh_BypassesLiveInspectResultCache(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test fixture uses a POSIX shell executable") + } + useIsolatedProviderManifest(t) + oldFx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true) + newFx := newManagedPackageFixtureInState(t, oldFx.stateDir, runtime.GOOS, runtime.GOARCH, true) + t.Setenv(openClawStateDirEnv, oldFx.stateDir) + t.Setenv(openClawConfigEnv, "") + + inspectOutput := filepath.Join(oldFx.stateDir, "inspect-output.json") + writeInspectOutput := func(fx optionalPackageFixture) { + t.Helper() + data := marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir)) + if err := os.WriteFile(inspectOutput, data, 0600); err != nil { + t.Fatal(err) + } + } + writeInspectOutput(oldFx) + + binDir := t.TempDir() + openClawPath := filepath.Join(binDir, "openclaw") + const script = "#!/bin/sh\n/bin/cat \"$OPENCLAW_STATE_DIR/inspect-output.json\"\n" + if err := os.WriteFile(openClawPath, []byte(script), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir) + + previousCached, previousFresh := runOpenClawInspect, runOpenClawInspectFresh + runOpenClawInspect, runOpenClawInspectFresh = executeOpenClawInspect, executeOpenClawInspectFresh + t.Cleanup(func() { + runOpenClawInspect, runOpenClawInspectFresh = previousCached, previousFresh + }) + + // This normal resolution populates the real process cache with oldFx. The + // cache key is unchanged when only inspect-output.json is replaced. + if _, err := Resolve(context.Background(), ProviderID); err != nil { + t.Fatal(err) + } + writeInspectOutput(newFx) + cachedData, err := executeOpenClawInspect(context.Background(), oldFx.stateDir) + if err != nil { + t.Fatal(err) + } + cachedPlugin, err := decodeInspectedPlugin(cachedData) + if err != nil { + t.Fatal(err) + } + if cachedPlugin.RootDir != oldFx.pluginDir { + t.Fatalf("cache precondition failed: rootDir = %q, want old %q", cachedPlugin.RootDir, oldFx.pluginDir) + } + if _, err := Refresh(context.Background(), ProviderID); err != nil { + t.Fatal(err) + } + manifest, err := readProviderManifest() + if err != nil { + t.Fatal(err) + } + if got := manifest.Providers[ProviderID].PackageDir; got != newFx.packageDir { + t.Fatalf("Refresh reused cached packageDir %q; want fresh %q", got, newFx.packageDir) + } +} + +func TestResolve_CorruptManifestSelfHeals(t *testing.T) { + configDir := useIsolatedProviderManifest(t) + fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true) + t.Setenv(openClawStateDirEnv, fx.stateDir) + if err := os.WriteFile(filepath.Join(configDir, providerManifestFileName), []byte(`{"version":`), 0600); err != nil { + t.Fatal(err) + } + data := marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir)) + var calls atomic.Int32 + stubInspectFunction(t, func(context.Context, string) ([]byte, error) { + calls.Add(1) + return data, nil + }) + if _, err := Resolve(context.Background(), ProviderID); err != nil { + t.Fatal(err) + } + if calls.Load() != 1 { + t.Fatalf("inspect calls = %d, want 1", calls.Load()) + } + if _, err := readProviderManifest(); err != nil { + t.Fatalf("manifest was not repaired: %v", err) + } +} + +func TestResolve_UnsafeManifestIsIgnoredAndReplaced(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX mode and symlink test") + } + configDir := useIsolatedProviderManifest(t) + fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true) + t.Setenv(openClawStateDirEnv, fx.stateDir) + data := marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir)) + var calls atomic.Int32 + stubInspectFunction(t, func(context.Context, string) ([]byte, error) { + calls.Add(1) + return data, nil + }) + if _, err := Resolve(context.Background(), ProviderID); err != nil { + t.Fatal(err) + } + + path := filepath.Join(configDir, providerManifestFileName) + if err := os.Chmod(path, 0666); err != nil { + t.Fatal(err) + } + if _, err := Resolve(context.Background(), ProviderID); err != nil { + t.Fatal(err) + } + if calls.Load() != 2 { + t.Fatalf("unsafe mode did not trigger discovery; calls = %d", calls.Load()) + } + if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("repaired manifest permissions = %v, %v", info, err) + } + + target := filepath.Join(t.TempDir(), "must-not-overwrite.json") + const targetContents = "outside" + if err := os.WriteFile(target, []byte(targetContents), 0600); err != nil { + t.Fatal(err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, path); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if _, err := Resolve(context.Background(), ProviderID); err != nil { + t.Fatal(err) + } + if calls.Load() != 3 { + t.Fatalf("manifest symlink did not trigger discovery; calls = %d", calls.Load()) + } + if info, err := os.Lstat(path); err != nil || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("manifest symlink was not safely replaced: %v, %v", info, err) + } + if contents, err := os.ReadFile(target); err != nil || string(contents) != targetContents { + t.Fatalf("symlink target changed: %q, %v", contents, err) + } +} + +func TestResolve_InvalidManifestDoesNotFallBackToStaleCommand(t *testing.T) { + useIsolatedProviderManifest(t) + fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true) + t.Setenv(openClawStateDirEnv, fx.stateDir) + data := marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir)) + stubInspectFunction(t, func(context.Context, string) ([]byte, error) { return data, nil }) + if _, err := Resolve(context.Background(), ProviderID); err != nil { + t.Fatal(err) + } + if err := os.Remove(fx.binary); err != nil { + t.Fatal(err) + } + stubInspectFunction(t, func(context.Context, string) ([]byte, error) { + return nil, errors.New("inspection unavailable") + }) + if _, err := Resolve(context.Background(), ProviderID); err == nil { + t.Fatal("Resolve reused an invalid manifest entry") + } +} + +func marshalInspectDocument(t *testing.T, document any) []byte { + t.Helper() + data, err := json.Marshal(document) + if err != nil { + t.Fatal(err) + } + return data +} + +func stubInspectFunction(t *testing.T, fn func(context.Context, string) ([]byte, error)) { + t.Helper() + previousCached, previousFresh := runOpenClawInspect, runOpenClawInspectFresh + runOpenClawInspect, runOpenClawInspectFresh = fn, fn + t.Cleanup(func() { + runOpenClawInspect, runOpenClawInspectFresh = previousCached, previousFresh + }) +} + +func newManagedPackageFixtureInState(t *testing.T, stateDir, goos, goarch string, hoisted bool) optionalPackageFixture { + t.Helper() + spec, err := signerPackageFor(goos, goarch) + if err != nil { + t.Skipf("host platform has no optional package: %v", err) + } + projectDir := filepath.Join(stateDir, "npm", "projects", "larksuite-openclaw-lark-next-generation") + nodeModules := filepath.Join(projectDir, "node_modules") + pluginDir := filepath.Join(nodeModules, signerPackageScope, pluginPackageName[len(signerPackageScope)+1:]) + packageNodeModules := filepath.Join(pluginDir, "node_modules") + if hoisted { + packageNodeModules = nodeModules + } + return writeOptionalPackageFixture(t, optionalPackageFixture{ + stateDir: stateDir, projectDir: projectDir, pluginDir: pluginDir, + packageDir: signerPackageUnder(packageNodeModules, spec), spec: spec, + }) +} diff --git a/internal/keylessprovider/provider.go b/internal/keylessprovider/provider.go new file mode 100644 index 0000000000..9769c9d72d --- /dev/null +++ b/internal/keylessprovider/provider.go @@ -0,0 +1,858 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package keylessprovider resolves the fixed keyless signer optional dependency +// installed with the OpenClaw Feishu plugin. Application config contains only +// the logical provider ID and keyRef; executable paths and argv are never read +// from application config, environment variables, or PATH. +package keylessprovider + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "sync" + "time" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/keylesshelper" + "github.com/larksuite/cli/internal/vfs" + "golang.org/x/sync/singleflight" +) + +const ( + ProviderID = core.KeylessProviderLarkSuite + + openClawStateDirEnv = "OPENCLAW_STATE_DIR" + openClawHomeEnv = "OPENCLAW_HOME" + openClawConfigEnv = "OPENCLAW_CONFIG_PATH" + openClawDirName = ".openclaw" + pluginID = "openclaw-lark" + pluginPackageName = "@larksuite/openclaw-lark" + signerPackageScope = "@larksuite" + signerBinaryBase = "lark-keyless-signer" + + metadataMaxBytes = 64 << 10 + binaryMaxBytes = 512 << 20 + inspectStdoutLimit = 4 << 20 + inspectStderrLimit = 64 << 10 + inspectCommandLimit = 30 * time.Second + inspectCacheTTL = 3 * time.Second +) + +var semver = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`) + +type platformPackage struct { + name string + npmOS string + npmCPU string + binaryName string +} + +type packageManifest struct { + Name string `json:"name"` + Version string `json:"version"` + OS []string `json:"os"` + CPU []string `json:"cpu"` +} + +type resolvedProvider struct { + stateDir string + pluginDir string + packageDir string + binaryPath string + packageVersion string + packageSize int64 + packageModTimeNS int64 + binarySize int64 + binaryModTimeNS int64 + digest string +} + +type inspectDependency struct { + Name string `json:"name"` + Installed bool `json:"installed"` + Optional bool `json:"optional"` + ResolvedPath string `json:"resolvedPath"` +} + +type inspectedPlugin struct { + ID string `json:"id"` + Name string `json:"name"` + PackageName string `json:"packageName"` + RootDir string `json:"rootDir"` + Status string `json:"status"` + DependencyStatus struct { + OptionalDependencies []inspectDependency `json:"optionalDependencies"` + } `json:"dependencyStatus"` +} + +type inspectUnavailableError struct{ cause error } + +func (e *inspectUnavailableError) Error() string { return e.cause.Error() } +func (e *inspectUnavailableError) Unwrap() error { return e.cause } + +var ( + runOpenClawInspect = executeOpenClawInspect + runOpenClawInspectFresh = executeOpenClawInspectFresh +) + +type inspectCacheEntry struct { + data []byte + expiresAt time.Time +} + +// inspectResultCache caches only OpenClaw's discovery document. Callers still +// validate the complete directory tree, package metadata, binary mode, and +// binary digest after every lookup, and keylesshelper re-hashes before exec. +// The short TTL removes duplicate OpenClaw startups within one CLI process +// without persisting an executable path across processes or plugin upgrades. +type inspectResultCache struct { + mu sync.Mutex + entries map[string]inspectCacheEntry + flights singleflight.Group +} + +var openClawInspectCache inspectResultCache + +// Resolve returns a freshly verified command for one provider operation. A +// global manifest avoids restarting OpenClaw when the previously bound package +// is unchanged; the package tree and binary digest are still revalidated. +func Resolve(ctx context.Context, provider string) (*keylesshelper.Command, error) { + command, resolved, err := resolve(ctx, provider, false) + if err != nil { + return nil, err + } + if resolved != nil { + // The manifest is a rebuildable performance index, not an + // authentication source. Runtime resolution remains available if its + // best-effort cache write fails. + _ = saveProviderManifest(*resolved, runtime.GOOS, runtime.GOARCH) + } + return command, nil +} + +// PrepareRefresh bypasses both the global manifest and the short-lived +// in-process OpenClaw inspection cache. It returns a freshly verified command +// and a deferred manifest commit. Callers that validate the signer before +// changing binding state must invoke commit only after that validation +// succeeds; abandoning the callback leaves the existing manifest unchanged. +func PrepareRefresh(ctx context.Context, provider string) (*keylesshelper.Command, func() error, error) { + command, resolved, err := resolve(ctx, provider, true) + if err != nil { + return nil, nil, err + } + if resolved == nil { + return command, func() error { return nil }, nil + } + candidate := *resolved + var once sync.Once + var commitErr error + commit := func() error { + once.Do(func() { + commitErr = revalidatePreparedProvider(candidate, runtime.GOOS, runtime.GOARCH) + if commitErr != nil { + return + } + commitErr = saveProviderManifest(candidate, runtime.GOOS, runtime.GOARCH) + }) + return commitErr + } + return command, commit, nil +} + +// revalidatePreparedProvider closes the gap between signer validation and a +// deferred manifest commit. It repeats the complete package validation and +// requires every captured property to remain identical before the candidate is +// allowed to replace the current manifest entry. +func revalidatePreparedProvider(candidate resolvedProvider, goos, goarch string) error { + spec, err := signerPackageFor(goos, goarch) + if err != nil { + return err + } + verified, err := resolvePackage(candidate.stateDir, candidate.pluginDir, candidate.packageDir, spec, goos) + if err != nil { + return fmt.Errorf("revalidate prepared keyless signer: %w", err) + } + if verified != candidate { + return fmt.Errorf("prepared keyless signer changed before manifest commit") + } + return nil +} + +// Refresh bypasses the global manifest and both discovers and records the +// signer reported by the current OpenClaw installation. Transactional bind +// flows should use PrepareRefresh so a failed signer validation cannot replace +// a previously working manifest entry. +func Refresh(ctx context.Context, provider string) (*keylesshelper.Command, error) { + command, commit, err := PrepareRefresh(ctx, provider) + if err != nil { + return nil, err + } + if commit != nil { + if err := commit(); err != nil { + return nil, fmt.Errorf("persist refreshed keyless signer manifest: %w", err) + } + } + return command, nil +} + +// resolve returns a non-nil resolvedProvider only for a newly discovered +// package. A manifest hit has already been persisted and therefore returns a +// nil resolvedProvider. +func resolve(ctx context.Context, provider string, forceRefresh bool) (*keylesshelper.Command, *resolvedProvider, error) { + if ctx == nil { + ctx = context.Background() + } + provider = strings.TrimSpace(provider) + if provider == "" { + return nil, nil, nil + } + if provider != ProviderID { + return nil, nil, fmt.Errorf("unknown keyless signer provider %q", provider) + } + + stateDir, err := openClawStateDir() + if err != nil { + return nil, nil, fmt.Errorf("resolve OpenClaw state directory: %w", err) + } + if !forceRefresh { + resolved, manifestHit := resolveFromProviderManifest(stateDir, runtime.GOOS, runtime.GOARCH) + if manifestHit { + command, err := keylesshelper.NewProviderCommand(resolved.binaryPath, resolved.packageDir, "", resolved.digest) + return command, nil, err + } + } + + inspectResolver := resolveFromInspect + if forceRefresh { + inspectResolver = resolveFromInspectFresh + } + resolved, inspectErr := inspectResolver(ctx, stateDir, runtime.GOOS, runtime.GOARCH) + if inspectErr != nil { + if err := ctx.Err(); err != nil { + return nil, nil, fmt.Errorf("%s provider inspection canceled: %w", ProviderID, err) + } + var unavailable *inspectUnavailableError + if !errors.As(inspectErr, &unavailable) { + return nil, nil, fmt.Errorf("%s provider is unavailable: %w", ProviderID, inspectErr) + } + // Older/local OpenClaw installations may not expose `plugins inspect`. + // The only fallback is the fixed extension-local package path; managed + // npm project directories are never scanned or guessed. + resolved, err = resolveFromStateDir(stateDir, runtime.GOOS, runtime.GOARCH) + if err != nil { + return nil, nil, fmt.Errorf("%s provider is unavailable: OpenClaw inspect failed (%w); fixed extension fallback failed: %w", ProviderID, inspectErr, err) + } + } + + // An empty home override deliberately preserves the recipient user's normal + // HOME. The helper still supplies a minimal environment and never inherits + // PATH, proxy, language-runtime, or loader injection variables. + command, err := keylesshelper.NewProviderCommand(resolved.binaryPath, resolved.packageDir, "", resolved.digest) + if err != nil { + return nil, nil, err + } + return command, &resolved, nil +} + +func resolveFromStateDir(stateDir, goos, goarch string) (resolvedProvider, error) { + spec, err := signerPackageFor(goos, goarch) + if err != nil { + return resolvedProvider{}, err + } + + stateDir, err = cleanAbsolutePath(stateDir) + if err != nil { + return resolvedProvider{}, fmt.Errorf("invalid OpenClaw state directory: %w", err) + } + pluginDir := filepath.Join(stateDir, "extensions", pluginID) + packageDir := signerPackageUnder(filepath.Join(pluginDir, "node_modules"), spec) + return resolvePackage(stateDir, pluginDir, packageDir, spec, goos) +} + +func resolveFromInspect(ctx context.Context, stateDir, goos, goarch string) (resolvedProvider, error) { + return resolveFromInspectUsing(ctx, stateDir, goos, goarch, runOpenClawInspect) +} + +func resolveFromInspectFresh(ctx context.Context, stateDir, goos, goarch string) (resolvedProvider, error) { + return resolveFromInspectUsing(ctx, stateDir, goos, goarch, runOpenClawInspectFresh) +} + +func resolveFromInspectUsing( + ctx context.Context, + stateDir, goos, goarch string, + inspect func(context.Context, string) ([]byte, error), +) (resolvedProvider, error) { + spec, err := signerPackageFor(goos, goarch) + if err != nil { + return resolvedProvider{}, err + } + stateDir, err = cleanAbsolutePath(stateDir) + if err != nil { + return resolvedProvider{}, fmt.Errorf("invalid OpenClaw state directory: %w", err) + } + data, err := inspect(ctx, stateDir) + if err != nil { + return resolvedProvider{}, err + } + plugin, err := decodeInspectedPlugin(data) + if err != nil { + return resolvedProvider{}, fmt.Errorf("parse OpenClaw plugin inspection: %w", err) + } + if plugin.ID != pluginID { + return resolvedProvider{}, fmt.Errorf("OpenClaw inspected unexpected plugin %q", plugin.ID) + } + if plugin.PackageName != "" && plugin.PackageName != pluginPackageName { + return resolvedProvider{}, fmt.Errorf("OpenClaw plugin package %q does not match %q", plugin.PackageName, pluginPackageName) + } + if plugin.Name != "" && plugin.Name != pluginPackageName && plugin.Name != "Feishu" { + return resolvedProvider{}, fmt.Errorf("OpenClaw plugin name %q is not recognized", plugin.Name) + } + if plugin.Status != "" && plugin.Status != "loaded" { + return resolvedProvider{}, fmt.Errorf("OpenClaw plugin is not loaded (status %q)", plugin.Status) + } + + pluginDir, err := cleanAbsolutePath(plugin.RootDir) + if err != nil { + return resolvedProvider{}, fmt.Errorf("invalid inspected plugin root: %w", err) + } + if err := ensureWithin(stateDir, pluginDir); err != nil { + return resolvedProvider{}, fmt.Errorf("inspected plugin root is outside OpenClaw state: %w", err) + } + + var dependency *inspectDependency + for i := range plugin.DependencyStatus.OptionalDependencies { + candidate := &plugin.DependencyStatus.OptionalDependencies[i] + if candidate.Name != spec.name { + continue + } + if dependency != nil { + return resolvedProvider{}, fmt.Errorf("OpenClaw inspection contains duplicate signer dependency %q", spec.name) + } + dependency = candidate + } + if dependency == nil || !dependency.Optional || !dependency.Installed || strings.TrimSpace(dependency.ResolvedPath) == "" { + return resolvedProvider{}, fmt.Errorf("OpenClaw plugin optional signer dependency %q is not installed", spec.name) + } + packageDir, err := cleanAbsolutePath(dependency.ResolvedPath) + if err != nil { + return resolvedProvider{}, fmt.Errorf("invalid inspected signer package path: %w", err) + } + if err := ensureWithin(stateDir, packageDir); err != nil { + return resolvedProvider{}, fmt.Errorf("inspected signer package is outside OpenClaw state: %w", err) + } + if !allowedPackageLocation(pluginDir, packageDir, spec, goos) { + return resolvedProvider{}, fmt.Errorf("inspected signer package is neither plugin-local nor managed-project-hoisted") + } + return resolvePackage(stateDir, pluginDir, packageDir, spec, goos) +} + +func resolvePackage(stateDir, pluginDir, packageDir string, spec platformPackage, goos string) (resolvedProvider, error) { + packageJSON := filepath.Join(packageDir, "package.json") + binDir := filepath.Join(packageDir, "bin") + binaryPath := filepath.Join(binDir, spec.binaryName) + for _, dir := range []string{pluginDir, packageDir, binDir} { + if err := validateDirectoryTree(stateDir, dir); err != nil { + return resolvedProvider{}, err + } + } + for _, file := range []string{packageJSON, binaryPath} { + if err := ensureWithin(stateDir, file); err != nil { + return resolvedProvider{}, err + } + if err := validateProviderObject(file, false); err != nil { + return resolvedProvider{}, err + } + } + + data, err := readMetadata(packageJSON) + if err != nil { + return resolvedProvider{}, fmt.Errorf("read signer package metadata: %w", err) + } + var manifest packageManifest + if err := decodeJSONObject(data, &manifest); err != nil { + return resolvedProvider{}, fmt.Errorf("parse signer package metadata: %w", err) + } + if manifest.Name != spec.name { + return resolvedProvider{}, fmt.Errorf("signer package name %q does not match %q", manifest.Name, spec.name) + } + if !semver.MatchString(manifest.Version) { + return resolvedProvider{}, fmt.Errorf("signer package version %q is not valid semver", manifest.Version) + } + if len(manifest.OS) != 1 || manifest.OS[0] != spec.npmOS { + return resolvedProvider{}, fmt.Errorf("signer package os metadata does not match %s", spec.npmOS) + } + if len(manifest.CPU) != 1 || manifest.CPU[0] != spec.npmCPU { + return resolvedProvider{}, fmt.Errorf("signer package cpu metadata does not match %s", spec.npmCPU) + } + packageInfo, err := vfs.Lstat(packageJSON) + if err != nil { + return resolvedProvider{}, fmt.Errorf("stat signer package metadata: %w", err) + } + + info, err := vfs.Lstat(binaryPath) + if err != nil { + return resolvedProvider{}, fmt.Errorf("stat signer binary: %w", err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return resolvedProvider{}, fmt.Errorf("signer binary is not a regular file") + } + if goos != "windows" && info.Mode().Perm()&0o111 == 0 { + return resolvedProvider{}, fmt.Errorf("signer binary is not executable") + } + digest, err := hashRegularFile(binaryPath, binaryMaxBytes) + if err != nil { + return resolvedProvider{}, fmt.Errorf("hash signer binary: %w", err) + } + + return resolvedProvider{ + stateDir: stateDir, + pluginDir: pluginDir, + packageDir: packageDir, + binaryPath: binaryPath, + packageVersion: manifest.Version, + packageSize: packageInfo.Size(), + packageModTimeNS: packageInfo.ModTime().UnixNano(), + binarySize: info.Size(), + binaryModTimeNS: info.ModTime().UnixNano(), + digest: digest, + }, nil +} + +func signerPackageFor(goos, goarch string) (platformPackage, error) { + key := goos + "/" + goarch + packages := map[string]platformPackage{ + "darwin/arm64": {"@larksuite/lark-keyless-signer-darwin-arm64", "darwin", "arm64", signerBinaryBase}, + "darwin/amd64": {"@larksuite/lark-keyless-signer-darwin-x64", "darwin", "x64", signerBinaryBase}, + "linux/arm64": {"@larksuite/lark-keyless-signer-linux-arm64", "linux", "arm64", signerBinaryBase}, + "linux/amd64": {"@larksuite/lark-keyless-signer-linux-x64", "linux", "x64", signerBinaryBase}, + "windows/amd64": {"@larksuite/lark-keyless-signer-win32-x64", "win32", "x64", signerBinaryBase + ".exe"}, + } + if spec, ok := packages[key]; ok { + return spec, nil + } + return platformPackage{}, fmt.Errorf("OpenClaw keyless signer optional package is unsupported on %s/%s", goos, goarch) +} + +func signerPackageUnder(nodeModules string, spec platformPackage) string { + return filepath.Join(nodeModules, signerPackageScope, strings.TrimPrefix(spec.name, signerPackageScope+"/")) +} + +func allowedPackageLocation(pluginDir, packageDir string, spec platformPackage, goos string) bool { + pluginLocal := signerPackageUnder(filepath.Join(pluginDir, "node_modules"), spec) + if samePath(pluginLocal, packageDir, goos) { + return true + } + + // npm-pack managed projects may hoist the optional dependency beside the + // plugin package: + // /node_modules/@larksuite/openclaw-lark + // /node_modules/@larksuite/lark-keyless-signer-... + pluginNodeModules := filepath.Dir(filepath.Dir(pluginDir)) + expectedPlugin := filepath.Join(pluginNodeModules, signerPackageScope, strings.TrimPrefix(pluginPackageName, signerPackageScope+"/")) + if filepath.Base(pluginNodeModules) != "node_modules" || !samePath(expectedPlugin, pluginDir, goos) { + return false + } + return samePath(signerPackageUnder(pluginNodeModules, spec), packageDir, goos) +} + +func samePath(left, right, goos string) bool { + left, right = filepath.Clean(left), filepath.Clean(right) + if goos == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func decodeInspectedPlugin(data []byte) (inspectedPlugin, error) { + if len(data) == 0 || len(data) > inspectStdoutLimit { + return inspectedPlugin{}, fmt.Errorf("inspection output size is invalid") + } + var envelope struct { + Plugin json.RawMessage `json:"plugin"` + } + if err := decodeJSONObject(data, &envelope); err != nil { + return inspectedPlugin{}, err + } + var plugin inspectedPlugin + if len(envelope.Plugin) != 0 && string(envelope.Plugin) != "null" { + if err := decodeJSONObject(envelope.Plugin, &plugin); err != nil { + return inspectedPlugin{}, err + } + } else if err := decodeJSONObject(data, &plugin); err != nil { + return inspectedPlugin{}, err + } + return plugin, nil +} + +func executeOpenClawInspect(ctx context.Context, stateDir string) ([]byte, error) { + if ctx == nil { + ctx = context.Background() + } + executable, cacheKey, err := prepareOpenClawInspect(stateDir) + if err != nil { + return nil, err + } + return openClawInspectCache.load(ctx, cacheKey, func(loadCtx context.Context) ([]byte, error) { + return executeOpenClawInspectCommand(loadCtx, stateDir, executable) + }) +} + +// executeOpenClawInspectFresh deliberately skips inspectResultCache, including +// its in-flight singleflight results. Bind/repair discovery must observe the +// current plugin generation even if normal resolution inspected the same +// OpenClaw installation during the preceding few seconds. +func executeOpenClawInspectFresh(ctx context.Context, stateDir string) ([]byte, error) { + if ctx == nil { + ctx = context.Background() + } + executable, _, err := prepareOpenClawInspect(stateDir) + if err != nil { + return nil, err + } + return executeOpenClawInspectCommand(ctx, stateDir, executable) +} + +func prepareOpenClawInspect(stateDir string) (string, string, error) { + executable, err := exec.LookPath("openclaw") + if err != nil { + return "", "", &inspectUnavailableError{cause: fmt.Errorf("openclaw executable not found: %w", err)} + } + if !filepath.IsAbs(executable) { + cwd, cwdErr := vfs.Getwd() + if cwdErr != nil { + return "", "", fmt.Errorf("resolve current directory for openclaw executable: %w", cwdErr) + } + executable = filepath.Join(cwd, executable) + } + executable, err = vfs.EvalSymlinks(executable) + if err != nil { + return "", "", fmt.Errorf("resolve openclaw executable symlinks: %w", err) + } + executable, err = cleanAbsolutePath(executable) + if err != nil { + return "", "", fmt.Errorf("validate openclaw executable path: %w", err) + } + if err := validateInspectExecutable(executable); err != nil { + return "", "", fmt.Errorf("validate openclaw executable: %w", err) + } + info, err := vfs.Lstat(executable) + if err != nil || runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { + return "", "", fmt.Errorf("openclaw executable is not executable") + } + cacheKey := strings.Join([]string{ + stateDir, + executable, + fmt.Sprintf("%d", info.Size()), + fmt.Sprintf("%d", info.ModTime().UnixNano()), + strings.TrimSpace(os.Getenv(openClawConfigEnv)), + }, "\x00") + return executable, cacheKey, nil +} + +func (c *inspectResultCache) load(ctx context.Context, key string, loader func(context.Context) ([]byte, error)) ([]byte, error) { + now := time.Now() + c.mu.Lock() + if entry, ok := c.entries[key]; ok && now.Before(entry.expiresAt) { + data := append([]byte(nil), entry.data...) + c.mu.Unlock() + return data, nil + } + c.mu.Unlock() + + resultCh := c.flights.DoChan(key, func() (any, error) { + now := time.Now() + c.mu.Lock() + if entry, ok := c.entries[key]; ok && now.Before(entry.expiresAt) { + data := append([]byte(nil), entry.data...) + c.mu.Unlock() + return data, nil + } + c.mu.Unlock() + + data, err := loader(ctx) + if err != nil { + return nil, err + } + data = append([]byte(nil), data...) + c.mu.Lock() + if c.entries == nil { + c.entries = make(map[string]inspectCacheEntry) + } + c.entries[key] = inspectCacheEntry{data: data, expiresAt: time.Now().Add(inspectCacheTTL)} + c.mu.Unlock() + return append([]byte(nil), data...), nil + }) + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case result := <-resultCh: + if result.Err != nil { + return nil, result.Err + } + data, ok := result.Val.([]byte) + if !ok { + return nil, fmt.Errorf("OpenClaw inspect cache returned an invalid result") + } + return append([]byte(nil), data...), nil + } +} + +func executeOpenClawInspectCommand(ctx context.Context, stateDir, executable string) ([]byte, error) { + inspectCtx, cancel := context.WithTimeout(ctx, inspectCommandLimit) + defer cancel() + cmd, err := newOpenClawInspectCommand(inspectCtx, executable, openClawInspectEnvironment(stateDir)) + if err != nil { + return nil, &inspectUnavailableError{cause: fmt.Errorf("prepare openclaw plugin inspection: %w", err)} + } + + stdout := &cappedBuffer{limit: inspectStdoutLimit} + stderr := &cappedBuffer{limit: inspectStderrLimit} + cmd.Stdout = stdout + cmd.Stderr = stderr + if err := cmd.Run(); err != nil { + if inspectCtx.Err() != nil { + return nil, &inspectUnavailableError{cause: fmt.Errorf("openclaw plugin inspection stopped: %w", inspectCtx.Err())} + } + return nil, &inspectUnavailableError{cause: fmt.Errorf("openclaw plugin inspection failed: %w (stderr omitted)", err)} + } + if stdout.exceeded { + return nil, fmt.Errorf("openclaw plugin inspection stdout exceeds %d bytes", inspectStdoutLimit) + } + if stderr.exceeded { + return nil, fmt.Errorf("openclaw plugin inspection stderr exceeds %d bytes", inspectStderrLimit) + } + return append([]byte(nil), stdout.Bytes()...), nil +} + +func openClawInspectEnvironment(stateDir string) []string { + keep := map[string]bool{ + "HOME": true, "PATH": true, "PATHEXT": true, "TMPDIR": true, "TEMP": true, "TMP": true, + "LANG": true, "LC_ALL": true, "USERPROFILE": true, "SYSTEMROOT": true, "WINDIR": true, + "COMSPEC": true, "APPDATA": true, "LOCALAPPDATA": true, + "OPENCLAW_HOME": true, openClawConfigEnv: true, + } + env := make([]string, 0, len(keep)+1) + for _, entry := range os.Environ() { + name := entry + if idx := strings.IndexByte(entry, '='); idx >= 0 { + name = entry[:idx] + } + if keep[strings.ToUpper(name)] { + env = append(env, entry) + } + } + return append(env, openClawStateDirEnv+"="+stateDir) +} + +type cappedBuffer struct { + bytes.Buffer + limit int + exceeded bool +} + +func (b *cappedBuffer) Write(data []byte) (int, error) { + originalLen := len(data) + remaining := b.limit - b.Len() + if remaining < len(data) { + b.exceeded = true + if remaining < 0 { + remaining = 0 + } + data = data[:remaining] + } + _, _ = b.Buffer.Write(data) + return originalLen, nil +} + +func validateDirectoryTree(root, target string) error { + root, err := cleanAbsolutePath(root) + if err != nil { + return err + } + target, err = cleanAbsolutePath(target) + if err != nil { + return err + } + if err := ensureWithin(root, target); err != nil { + return err + } + if err := validateProviderObject(root, true); err != nil { + return err + } + rel, err := filepath.Rel(root, target) + if err != nil { + return err + } + current := root + if rel == "." { + return nil + } + for _, part := range strings.Split(rel, string(filepath.Separator)) { + if part == "" || part == "." || part == ".." { + return fmt.Errorf("provider directory tree contains invalid component %q", part) + } + current = filepath.Join(current, part) + if err := validateProviderObject(current, true); err != nil { + return err + } + } + return nil +} + +func openClawStateDir() (string, error) { + if stateDir := strings.TrimSpace(os.Getenv(openClawStateDirEnv)); stateDir != "" { + return resolveOpenClawPath(stateDir) + } + if configPath := strings.TrimSpace(os.Getenv(openClawConfigEnv)); configPath != "" { + resolved, err := resolveOpenClawPath(configPath) + if err != nil { + return "", fmt.Errorf("invalid %s: %w", openClawConfigEnv, err) + } + return cleanAbsolutePath(filepath.Dir(resolved)) + } + home, err := openClawEffectiveHome() + if err != nil { + return "", err + } + return cleanAbsolutePath(filepath.Join(home, openClawDirName)) +} + +func resolveOpenClawPath(path string) (string, error) { + home, err := openClawEffectiveHome() + if err != nil { + return "", err + } + return expandWithHomeAndClean(path, home) +} + +func openClawEffectiveHome() (string, error) { + osHome, err := vfs.UserHomeDir() + if err != nil || strings.TrimSpace(osHome) == "" { + return "", fmt.Errorf("resolve user home: %w", err) + } + osHome, err = cleanAbsolutePath(filepath.Clean(osHome)) + if err != nil { + return "", fmt.Errorf("invalid user home: %w", err) + } + if configured := strings.TrimSpace(os.Getenv(openClawHomeEnv)); configured != "" { + home, err := expandWithHomeAndClean(configured, osHome) + if err != nil { + return "", fmt.Errorf("invalid %s: %w", openClawHomeEnv, err) + } + return home, nil + } + return osHome, nil +} + +func expandWithHomeAndClean(path, home string) (string, error) { + if path == "~" || strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) { + if path == "~" { + path = home + } else { + path = filepath.Join(home, path[2:]) + } + } else if strings.HasPrefix(path, "~") { + return "", fmt.Errorf("named-user home expansion is not supported") + } + return cleanAbsolutePath(path) +} + +func cleanAbsolutePath(path string) (string, error) { + if strings.IndexByte(path, 0) >= 0 { + return "", fmt.Errorf("path contains NUL") + } + if !filepath.IsAbs(path) { + return "", fmt.Errorf("path must be absolute") + } + clean := filepath.Clean(path) + if clean != path { + return "", fmt.Errorf("path must be clean (got %q)", path) + } + return clean, nil +} + +func ensureWithin(root, path string) error { + rel, err := filepath.Rel(root, path) + if err != nil { + return fmt.Errorf("check signer path boundary: %w", err) + } + if rel == ".." || filepath.IsAbs(rel) || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("signer path escapes OpenClaw state directory") + } + return nil +} + +func decodeJSONObject(data []byte, out any) error { + trimmed := strings.TrimSpace(string(data)) + if !strings.HasPrefix(trimmed, "{") || !strings.HasSuffix(trimmed, "}") { + return fmt.Errorf("expected JSON object") + } + decoder := json.NewDecoder(strings.NewReader(trimmed)) + if err := decoder.Decode(out); err != nil { + return err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return fmt.Errorf("multiple JSON values are not allowed") + } + return err + } + return nil +} + +func readMetadata(path string) ([]byte, error) { + info, err := vfs.Lstat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() < 2 || info.Size() > metadataMaxBytes { + return nil, fmt.Errorf("metadata size/type is invalid") + } + data, err := vfs.ReadFile(path) + if err != nil { + return nil, err + } + if len(data) > metadataMaxBytes { + return nil, fmt.Errorf("metadata exceeds %d bytes", metadataMaxBytes) + } + return data, nil +} + +func hashRegularFile(path string, maxSize int64) (string, error) { + info, err := vfs.Lstat(path) + if err != nil { + return "", err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() <= 0 || info.Size() > maxSize { + return "", fmt.Errorf("file size/type is invalid") + } + f, err := vfs.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + n, err := io.Copy(h, io.LimitReader(f, maxSize+1)) + if err != nil { + return "", err + } + if n != info.Size() || n > maxSize { + return "", fmt.Errorf("file changed while hashing") + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/keylessprovider/provider_test.go b/internal/keylessprovider/provider_test.go new file mode 100644 index 0000000000..0a58fcf0a0 --- /dev/null +++ b/internal/keylessprovider/provider_test.go @@ -0,0 +1,537 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package keylessprovider + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" +) + +type optionalPackageFixture struct { + stateDir, projectDir, pluginDir string + packageDir, packageJSON, binDir, binary string + spec platformPackage + binaryDigest string +} + +func TestSignerPackageFor(t *testing.T) { + tests := []struct { + goos, goarch, name, npmOS, npmCPU, binary string + }{ + {"darwin", "arm64", "@larksuite/lark-keyless-signer-darwin-arm64", "darwin", "arm64", signerBinaryBase}, + {"darwin", "amd64", "@larksuite/lark-keyless-signer-darwin-x64", "darwin", "x64", signerBinaryBase}, + {"linux", "arm64", "@larksuite/lark-keyless-signer-linux-arm64", "linux", "arm64", signerBinaryBase}, + {"linux", "amd64", "@larksuite/lark-keyless-signer-linux-x64", "linux", "x64", signerBinaryBase}, + {"windows", "amd64", "@larksuite/lark-keyless-signer-win32-x64", "win32", "x64", signerBinaryBase + ".exe"}, + } + for _, test := range tests { + t.Run(test.goos+"_"+test.goarch, func(t *testing.T) { + got, err := signerPackageFor(test.goos, test.goarch) + if err != nil { + t.Fatal(err) + } + if got.name != test.name || got.npmOS != test.npmOS || got.npmCPU != test.npmCPU || got.binaryName != test.binary { + t.Fatalf("spec = %#v", got) + } + }) + } + for _, unsupported := range [][2]string{{"windows", "arm64"}, {"linux", "riscv64"}, {"freebsd", "amd64"}} { + if _, err := signerPackageFor(unsupported[0], unsupported[1]); err == nil { + t.Fatalf("%s/%s unexpectedly supported", unsupported[0], unsupported[1]) + } + } +} + +func TestResolveFromStateDir_OptionalPackage(t *testing.T) { + fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH) + got, err := resolveFromStateDir(fx.stateDir, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + if got.binaryPath != fx.binary || got.packageDir != fx.packageDir || got.digest != fx.binaryDigest { + t.Fatalf("resolved = %#v, fixture = %#v", got, fx) + } +} + +func TestResolve_UsesOpenClawStateDirAndFixedExtensionFallback(t *testing.T) { + useIsolatedProviderManifest(t) + fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH) + t.Setenv(openClawStateDirEnv, fx.stateDir) + t.Setenv(openClawHomeEnv, filepath.Join(t.TempDir(), "must-not-win")) + t.Setenv("PATH", "") // force the fixed extensions fallback + command, err := Resolve(context.Background(), ProviderID) + if err != nil { + t.Fatal(err) + } + if command == nil { + t.Fatal("Resolve returned nil command") + } +} + +func TestResolveFromInspect_ManagedProjectPluginLocal(t *testing.T) { + fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, false) + got, err := resolveFromInspectDocument(t, fx, inspectPluginDocument(fx, pluginID, fx.packageDir)) + if err != nil { + t.Fatal(err) + } + assertResolvedFixture(t, got, fx) +} + +func TestResolveFromInspect_ManagedProjectHoisted(t *testing.T) { + fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true) + got, err := resolveFromInspectDocument(t, fx, inspectPluginDocument(fx, pluginID, fx.packageDir)) + if err != nil { + t.Fatal(err) + } + assertResolvedFixture(t, got, fx) +} + +func TestResolveFromInspect_RejectsWrongPluginAndEscapingPaths(t *testing.T) { + t.Run("wrong plugin", func(t *testing.T) { + fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true) + _, err := resolveFromInspectDocument(t, fx, inspectPluginDocument(fx, "evil-plugin", fx.packageDir)) + if err == nil || !strings.Contains(err.Error(), "unexpected plugin") { + t.Fatalf("error = %v", err) + } + }) + t.Run("package outside state", func(t *testing.T) { + fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true) + outside := filepath.Join(t.TempDir(), "node_modules", signerPackageScope, strings.TrimPrefix(fx.spec.name, signerPackageScope+"/")) + _, err := resolveFromInspectDocument(t, fx, inspectPluginDocument(fx, pluginID, outside)) + if err == nil || !strings.Contains(err.Error(), "outside OpenClaw state") { + t.Fatalf("error = %v", err) + } + }) + t.Run("unrelated package inside state", func(t *testing.T) { + fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true) + unrelated := signerPackageUnder(filepath.Join(fx.stateDir, "other", "node_modules"), fx.spec) + _, err := resolveFromInspectDocument(t, fx, inspectPluginDocument(fx, pluginID, unrelated)) + if err == nil || !strings.Contains(err.Error(), "neither plugin-local nor managed-project-hoisted") { + t.Fatalf("error = %v", err) + } + }) + t.Run("plugin outside state", func(t *testing.T) { + fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true) + document := inspectPluginDocument(fx, pluginID, fx.packageDir) + plugin := document["plugin"].(map[string]any) + plugin["rootDir"] = filepath.Join(t.TempDir(), "openclaw-lark") + _, err := resolveFromInspectDocument(t, fx, document) + if err == nil || !strings.Contains(err.Error(), "outside OpenClaw state") { + t.Fatalf("error = %v", err) + } + }) +} + +func TestResolve_InspectUnavailableUsesOnlyFixedExtensionFallback(t *testing.T) { + useIsolatedProviderManifest(t) + fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH) + t.Setenv(openClawStateDirEnv, fx.stateDir) + stubOpenClawInspect(t, nil, &inspectUnavailableError{cause: errors.New("not installed")}) + command, err := Resolve(context.Background(), ProviderID) + if err != nil || command == nil { + t.Fatalf("Resolve = %v, %v", command, err) + } +} + +func TestResolve_InspectUnavailableDoesNotScanManagedProjects(t *testing.T) { + useIsolatedProviderManifest(t) + fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true) + t.Setenv(openClawStateDirEnv, fx.stateDir) + stubOpenClawInspect(t, nil, &inspectUnavailableError{cause: errors.New("not installed")}) + if _, err := Resolve(context.Background(), ProviderID); err == nil || !strings.Contains(err.Error(), "fixed extension fallback failed") { + t.Fatalf("error = %v", err) + } +} + +func TestResolve_InvalidInspectDoesNotFallBack(t *testing.T) { + useIsolatedProviderManifest(t) + fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH) + t.Setenv(openClawStateDirEnv, fx.stateDir) + stubOpenClawInspect(t, []byte(`{"plugin":`), nil) + if _, err := Resolve(context.Background(), ProviderID); err == nil || !strings.Contains(err.Error(), "parse OpenClaw") { + t.Fatalf("error = %v", err) + } +} + +func TestResolve_InspectCancellationHonorsCallerContext(t *testing.T) { + useIsolatedProviderManifest(t) + fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH) + t.Setenv(openClawStateDirEnv, fx.stateDir) + previous := runOpenClawInspect + runOpenClawInspect = func(ctx context.Context, _ string) ([]byte, error) { + <-ctx.Done() + return nil, &inspectUnavailableError{cause: ctx.Err()} + } + t.Cleanup(func() { runOpenClawInspect = previous }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := Resolve(ctx, ProviderID); !errors.Is(err, context.Canceled) { + t.Fatalf("Resolve error = %v, want context.Canceled", err) + } +} + +func TestInspectResultCache_ShortLivedAndSingleflight(t *testing.T) { + var cache inspectResultCache + var calls atomic.Int32 + started := make(chan struct{}) + release := make(chan struct{}) + loader := func(context.Context) ([]byte, error) { + if calls.Add(1) == 1 { + close(started) + } + <-release + return []byte(`{"plugin":{"id":"openclaw-lark"}}`), nil + } + + const callers = 8 + results := make(chan []byte, callers) + errs := make(chan error, callers) + var wg sync.WaitGroup + for i := 0; i < callers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + data, err := cache.load(context.Background(), "same-install", loader) + results <- data + errs <- err + }() + } + <-started + close(release) + wg.Wait() + close(results) + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + for data := range results { + if string(data) != `{"plugin":{"id":"openclaw-lark"}}` { + t.Fatalf("cached data = %q", data) + } + if len(data) > 0 { + data[0] = 'x' + } + } + if got := calls.Load(); got != 1 { + t.Fatalf("loader calls = %d, want 1", got) + } + data, err := cache.load(context.Background(), "same-install", loader) + if err != nil || string(data) != `{"plugin":{"id":"openclaw-lark"}}` || calls.Load() != 1 { + t.Fatalf("cache hit = %q, %v, calls %d", data, err, calls.Load()) + } +} + +func TestInspectResultCache_WaiterHonorsContext(t *testing.T) { + var cache inspectResultCache + started := make(chan struct{}) + release := make(chan struct{}) + loader := func(context.Context) ([]byte, error) { + close(started) + <-release + return []byte(`{}`), nil + } + go func() { + _, _ = cache.load(context.Background(), "busy", loader) + }() + <-started + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := cache.load(ctx, "busy", loader); !errors.Is(err, context.Canceled) { + t.Fatalf("cache waiter error = %v, want context.Canceled", err) + } + close(release) +} + +func TestInspectOutputAndEnvironmentAreBounded(t *testing.T) { + buffer := &cappedBuffer{limit: 4} + if n, err := buffer.Write([]byte("123456")); err != nil || n != 6 || !buffer.exceeded || buffer.String() != "1234" { + t.Fatalf("bounded write = n %d exceeded %v data %q err %v", n, buffer.exceeded, buffer.String(), err) + } + t.Setenv("HOME", "/safe-home") + t.Setenv("NODE_OPTIONS", "--require=/must-not-load") + t.Setenv(openClawStateDirEnv, "/must-not-win") + env := strings.Join(openClawInspectEnvironment("/selected-state"), "\n") + if !strings.Contains(env, "HOME=/safe-home") || !strings.Contains(env, openClawStateDirEnv+"=/selected-state") || + strings.Contains(env, "NODE_OPTIONS") || strings.Contains(env, openClawStateDirEnv+"=/must-not-win") { + t.Fatalf("inspect environment = %q", env) + } +} + +func TestResolve_UnknownProviderFailsClosed(t *testing.T) { + useIsolatedProviderManifest(t) + if _, err := Resolve(context.Background(), "evil.provider"); err == nil || !strings.Contains(err.Error(), "unknown") { + t.Fatalf("error = %v", err) + } +} + +func useIsolatedProviderManifest(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + return dir +} + +func TestOpenClawStateDirPriorityAndExpansion(t *testing.T) { + home := t.TempDir() + state := filepath.Join(t.TempDir(), "custom state") + t.Setenv("HOME", home) + t.Setenv(openClawConfigEnv, "") + t.Setenv(openClawHomeEnv, filepath.Join(t.TempDir(), "must-not-win")) + t.Setenv(openClawStateDirEnv, state) + if got, err := openClawStateDir(); err != nil || got != state { + t.Fatalf("state dir = %q, %v", got, err) + } + + t.Setenv(openClawStateDirEnv, "") + openClawHome := filepath.Join(t.TempDir(), "openclaw home") + t.Setenv(openClawHomeEnv, openClawHome) + if got, err := openClawStateDir(); err != nil || got != filepath.Join(openClawHome, openClawDirName) { + t.Fatalf("OPENCLAW_HOME state dir = %q, %v", got, err) + } + + t.Setenv(openClawHomeEnv, "") + t.Setenv(openClawStateDirEnv, "~/custom-openclaw") + if got, err := openClawStateDir(); err != nil || got != filepath.Join(home, "custom-openclaw") { + t.Fatalf("expanded state dir = %q, %v", got, err) + } +} + +func TestOpenClawStateDir_ConfigPathPrecedesHome(t *testing.T) { + osHome := t.TempDir() + openClawHome := filepath.Join(t.TempDir(), "openclaw-home") + t.Setenv("HOME", osHome) + t.Setenv(openClawStateDirEnv, "") + t.Setenv(openClawHomeEnv, openClawHome) + t.Setenv(openClawConfigEnv, "~/profiles/team/openclaw.json") + + want := filepath.Join(openClawHome, "profiles", "team") + if got, err := openClawStateDir(); err != nil || got != want { + t.Fatalf("state dir = %q, %v; want %q", got, err, want) + } + + explicitState := filepath.Join(t.TempDir(), "state-wins") + t.Setenv(openClawStateDirEnv, explicitState) + if got, err := openClawStateDir(); err != nil || got != explicitState { + t.Fatalf("explicit state dir = %q, %v; want %q", got, err, explicitState) + } +} + +func TestOpenClawStateDirRejectsRelativeOrUncleanPath(t *testing.T) { + unclean := filepath.Join(t.TempDir(), "child") + string(filepath.Separator) + ".." + string(filepath.Separator) + "state" + for _, path := range []string{"relative/state", unclean} { + t.Run(strings.ReplaceAll(path, string(filepath.Separator), "_"), func(t *testing.T) { + t.Setenv(openClawStateDirEnv, path) + if _, err := openClawStateDir(); err == nil { + t.Fatalf("path %q was accepted", path) + } + }) + } +} + +func TestResolveFromStateDir_ValidatesPackageMetadata(t *testing.T) { + tests := []struct { + name string + mutate func(*packageManifest) + want string + }{ + {"name", func(m *packageManifest) { m.Name = "@evil/signer" }, "name"}, + {"version", func(m *packageManifest) { m.Version = "latest" }, "version"}, + {"os", func(m *packageManifest) { m.OS = []string{"other"} }, "os metadata"}, + {"cpu", func(m *packageManifest) { m.CPU = []string{"other"} }, "cpu metadata"}, + {"multiple os", func(m *packageManifest) { m.OS = append(m.OS, "other") }, "os metadata"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH) + manifest := validManifest(fx.spec) + test.mutate(&manifest) + writeJSON(t, fx.packageJSON, manifest, 0600) + if _, err := resolveFromStateDir(fx.stateDir, runtime.GOOS, runtime.GOARCH); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v", err) + } + }) + } +} + +func TestResolveFromStateDir_RejectsParentAndBinarySymlinks(t *testing.T) { + t.Run("parent", func(t *testing.T) { + fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH) + realBin := fx.binDir + "-real" + if err := os.Rename(fx.binDir, realBin); err != nil { + t.Fatal(err) + } + if err := os.Symlink(realBin, fx.binDir); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if _, err := resolveFromStateDir(fx.stateDir, runtime.GOOS, runtime.GOARCH); err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("binary", func(t *testing.T) { + fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH) + realBinary := fx.binary + "-real" + if err := os.Rename(fx.binary, realBinary); err != nil { + t.Fatal(err) + } + if err := os.Symlink(realBinary, fx.binary); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if _, err := resolveFromStateDir(fx.stateDir, runtime.GOOS, runtime.GOARCH); err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("error = %v", err) + } + }) +} + +func TestResolveFromStateDir_RejectsInsecureModeAndNonExecutable(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX mode test") + } + t.Run("writable parent", func(t *testing.T) { + fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH) + if err := os.Chmod(fx.packageDir, 0777); err != nil { + t.Fatal(err) + } + if _, err := resolveFromStateDir(fx.stateDir, runtime.GOOS, runtime.GOARCH); err == nil || !strings.Contains(err.Error(), "writable") { + t.Fatalf("error = %v", err) + } + }) + t.Run("non executable", func(t *testing.T) { + fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH) + if err := os.Chmod(fx.binary, 0600); err != nil { + t.Fatal(err) + } + if _, err := resolveFromStateDir(fx.stateDir, runtime.GOOS, runtime.GOARCH); err == nil || !strings.Contains(err.Error(), "executable") { + t.Fatalf("error = %v", err) + } + }) +} + +func TestResolveFromStateDir_MissingPackageFailsClosed(t *testing.T) { + stateDir := t.TempDir() + if _, err := resolveFromStateDir(stateDir, runtime.GOOS, runtime.GOARCH); err == nil { + t.Fatal("missing optional package was accepted") + } +} + +func newOptionalPackageFixture(t *testing.T, goos, goarch string) optionalPackageFixture { + t.Helper() + spec, err := signerPackageFor(goos, goarch) + if err != nil { + t.Skipf("host platform has no optional package: %v", err) + } + stateDir := filepath.Join(t.TempDir(), "openclaw state") + pluginDir := filepath.Join(stateDir, "extensions", pluginID) + packageDir := signerPackageUnder(filepath.Join(pluginDir, "node_modules"), spec) + return writeOptionalPackageFixture(t, optionalPackageFixture{ + stateDir: stateDir, pluginDir: pluginDir, packageDir: packageDir, spec: spec, + }) +} + +func newManagedPackageFixture(t *testing.T, goos, goarch string, hoisted bool) optionalPackageFixture { + t.Helper() + spec, err := signerPackageFor(goos, goarch) + if err != nil { + t.Skipf("host platform has no optional package: %v", err) + } + stateDir := filepath.Join(t.TempDir(), "custom OpenClaw state") + projectDir := filepath.Join(stateDir, "npm", "projects", "larksuite-openclaw-lark-test-generation") + nodeModules := filepath.Join(projectDir, "node_modules") + pluginDir := filepath.Join(nodeModules, signerPackageScope, strings.TrimPrefix(pluginPackageName, signerPackageScope+"/")) + packageNodeModules := filepath.Join(pluginDir, "node_modules") + if hoisted { + packageNodeModules = nodeModules + } + packageDir := signerPackageUnder(packageNodeModules, spec) + return writeOptionalPackageFixture(t, optionalPackageFixture{ + stateDir: stateDir, projectDir: projectDir, pluginDir: pluginDir, packageDir: packageDir, spec: spec, + }) +} + +func writeOptionalPackageFixture(t *testing.T, fx optionalPackageFixture) optionalPackageFixture { + t.Helper() + if err := os.MkdirAll(fx.pluginDir, 0700); err != nil { + t.Fatal(err) + } + packageDir := fx.packageDir + binDir := filepath.Join(packageDir, "bin") + if err := os.MkdirAll(binDir, 0700); err != nil { + t.Fatal(err) + } + packageJSON := filepath.Join(packageDir, "package.json") + writeJSON(t, packageJSON, validManifest(fx.spec), 0600) + binary := filepath.Join(binDir, fx.spec.binaryName) + binaryBytes := []byte("test optional-package signer") + if err := os.WriteFile(binary, binaryBytes, 0700); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(binaryBytes) + fx.packageJSON, fx.binDir, fx.binary = packageJSON, binDir, binary + fx.binaryDigest = hex.EncodeToString(digest[:]) + return fx +} + +func inspectPluginDocument(fx optionalPackageFixture, inspectedID, resolvedPath string) map[string]any { + return map[string]any{"plugin": map[string]any{ + "id": inspectedID, "name": pluginPackageName, "packageName": pluginPackageName, + "rootDir": fx.pluginDir, "status": "loaded", + "dependencyStatus": map[string]any{"optionalDependencies": []map[string]any{{ + "name": fx.spec.name, "installed": true, "optional": true, "resolvedPath": resolvedPath, + }}}, + }} +} + +func resolveFromInspectDocument(t *testing.T, fx optionalPackageFixture, document any) (resolvedProvider, error) { + t.Helper() + data, err := json.Marshal(document) + if err != nil { + t.Fatal(err) + } + stubOpenClawInspect(t, data, nil) + return resolveFromInspect(context.Background(), fx.stateDir, runtime.GOOS, runtime.GOARCH) +} + +func stubOpenClawInspect(t *testing.T, data []byte, err error) { + t.Helper() + previous := runOpenClawInspect + runOpenClawInspect = func(context.Context, string) ([]byte, error) { return data, err } + t.Cleanup(func() { runOpenClawInspect = previous }) +} + +func assertResolvedFixture(t *testing.T, got resolvedProvider, fx optionalPackageFixture) { + t.Helper() + if got.binaryPath != fx.binary || got.packageDir != fx.packageDir || got.digest != fx.binaryDigest { + t.Fatalf("resolved = %#v, fixture = %#v", got, fx) + } +} + +func validManifest(spec platformPackage) packageManifest { + return packageManifest{Name: spec.name, Version: "1.2.3", OS: []string{spec.npmOS}, CPU: []string{spec.npmCPU}} +} + +func writeJSON(t *testing.T, path string, value any, mode os.FileMode) { + t.Helper() + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatal(err) + } + data = append(data, '\n') + if err := os.WriteFile(path, data, mode); err != nil { + t.Fatal(err) + } +} diff --git a/internal/keylessprovider/security_unix.go b/internal/keylessprovider/security_unix.go new file mode 100644 index 0000000000..0207969948 --- /dev/null +++ b/internal/keylessprovider/security_unix.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build darwin || linux + +package keylessprovider + +import ( + "fmt" + "os" + "syscall" + + "github.com/larksuite/cli/internal/vfs" +) + +func validateProviderObject(path string, wantDir bool) error { + return validateOwnedObject(path, wantDir) +} + +func validateInspectExecutable(path string) error { + info, err := vfs.Lstat(path) + if err != nil { + return fmt.Errorf("inspect OpenClaw executable: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("OpenClaw executable must be a regular non-symlink file") + } + if info.Mode().Perm()&0o022 != 0 { + return fmt.Errorf("OpenClaw executable is group/world writable") + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || int(stat.Uid) != os.Getuid() && stat.Uid != 0 { + return fmt.Errorf("OpenClaw executable is not owned by the current user or root") + } + if stat.Nlink != 1 { + return fmt.Errorf("OpenClaw executable must have exactly one hard link") + } + return nil +} + +func validateOwnedObject(path string, wantDir bool) error { + info, err := vfs.Lstat(path) + if err != nil { + return fmt.Errorf("inspect provider object %s: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("provider object must not be a symlink: %s", path) + } + if wantDir != info.IsDir() || (!wantDir && !info.Mode().IsRegular()) { + return fmt.Errorf("provider object has unexpected type: %s", path) + } + if info.Mode().Perm()&0o022 != 0 { + return fmt.Errorf("provider object is group/world writable: %s (mode %o)", path, info.Mode().Perm()) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || int(stat.Uid) != os.Getuid() { + return fmt.Errorf("provider object is not owned by the current user: %s", path) + } + if !wantDir && stat.Nlink != 1 { + return fmt.Errorf("provider file must have exactly one hard link: %s", path) + } + return nil +} diff --git a/internal/keylessprovider/security_windows.go b/internal/keylessprovider/security_windows.go new file mode 100644 index 0000000000..6b799a013a --- /dev/null +++ b/internal/keylessprovider/security_windows.go @@ -0,0 +1,149 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build windows + +package keylessprovider + +import ( + "fmt" + "os" + "unsafe" + + "github.com/larksuite/cli/internal/vfs" + "golang.org/x/sys/windows" +) + +func validateProviderObject(path string, wantDir bool) error { + info, err := vfs.Lstat(path) + if err != nil { + return fmt.Errorf("inspect provider object %s: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("provider object must not be a symlink: %s", path) + } + if wantDir != info.IsDir() || (!wantDir && !info.Mode().IsRegular()) { + return fmt.Errorf("provider object has unexpected type: %s", path) + } + + path16, err := windows.UTF16PtrFromString(path) + if err != nil { + return fmt.Errorf("encode provider object path: %w", err) + } + attrs, err := windows.GetFileAttributes(path16) + if err != nil { + return fmt.Errorf("inspect provider object attributes: %w", err) + } + if attrs&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return fmt.Errorf("provider object must not be a reparse point: %s", path) + } + + sd, err := windows.GetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return fmt.Errorf("inspect provider object security descriptor: %w", err) + } + owner, _, err := sd.Owner() + if err != nil || owner == nil { + return fmt.Errorf("inspect provider object owner: %w", err) + } + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return fmt.Errorf("inspect current Windows user: %w", err) + } + if !owner.Equals(user.User.Sid) { + return fmt.Errorf("provider object is not owned by the current user: %s", path) + } + if err := validateWindowsDACL(sd, user.User.Sid); err != nil { + return fmt.Errorf("provider object has unsafe permissions: %s: %w", path, err) + } + return nil +} + +func validateInspectExecutable(path string) error { + info, err := vfs.Lstat(path) + if err != nil { + return fmt.Errorf("inspect OpenClaw executable: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("OpenClaw executable must be a regular non-symlink file") + } + path16, err := windows.UTF16PtrFromString(path) + if err != nil { + return err + } + attrs, err := windows.GetFileAttributes(path16) + if err != nil || attrs&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return fmt.Errorf("OpenClaw executable must not be a reparse point") + } + sd, err := windows.GetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return err + } + owner, _, err := sd.Owner() + if err != nil || owner == nil { + return fmt.Errorf("inspect OpenClaw executable owner: %w", err) + } + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return err + } + if !owner.Equals(user.User.Sid) && + !owner.IsWellKnown(windows.WinLocalSystemSid) && + !owner.IsWellKnown(windows.WinBuiltinAdministratorsSid) { + return fmt.Errorf("OpenClaw executable has an untrusted owner") + } + if err := validateWindowsDACL(sd, user.User.Sid); err != nil { + return fmt.Errorf("OpenClaw executable has unsafe permissions: %w", err) + } + return nil +} + +func validateWindowsDACL(sd *windows.SECURITY_DESCRIPTOR, currentUser *windows.SID) error { + dacl, _, err := sd.DACL() + if err != nil || dacl == nil { + return fmt.Errorf("missing or unreadable DACL") + } + const ( + fileDeleteChild windows.ACCESS_MASK = 0x00000040 + writeMask = windows.GENERIC_ALL | windows.GENERIC_WRITE | + windows.WRITE_DAC | windows.WRITE_OWNER | windows.DELETE | + windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA | + windows.FILE_WRITE_EA | windows.FILE_WRITE_ATTRIBUTES | fileDeleteChild + ) + for i := uint16(0); i < dacl.AceCount; i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, uint32(i), &ace); err != nil { + return fmt.Errorf("read ACL entry %d: %w", i, err) + } + if ace == nil || ace.Header.AceType == windows.ACCESS_DENIED_ACE_TYPE { + continue + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + return fmt.Errorf("unsupported allow ACL entry type %d", ace.Header.AceType) + } + if ace.Mask&writeMask == 0 { + continue + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + if sid == nil || !sid.IsValid() { + return fmt.Errorf("ACL entry %d has invalid SID", i) + } + if sid.Equals(currentUser) || + sid.IsWellKnown(windows.WinLocalSystemSid) || + sid.IsWellKnown(windows.WinBuiltinAdministratorsSid) || + sid.IsWellKnown(windows.WinCreatorOwnerSid) || + sid.IsWellKnown(windows.WinCreatorOwnerRightsSid) { + continue + } + return fmt.Errorf("write access is granted to SID %s", sid.String()) + } + return nil +} diff --git a/internal/keysigner/keysigner.go b/internal/keysigner/keysigner.go new file mode 100644 index 0000000000..1497827daa --- /dev/null +++ b/internal/keysigner/keysigner.go @@ -0,0 +1,210 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package keysigner defines the pluggable signing abstraction used by the +// private_key_jwt registration and authentication flow. +// +// Platform implementations hold non-exportable private keys in TPM 2.0 on +// supported Linux/Windows targets and in Keychain on macOS. Build constraints +// select the implementation for each target, and each backend registers itself +// through Register from init(). +package keysigner + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "crypto/x509" + "encoding/asn1" + "encoding/base64" + "errors" + "fmt" + "math/big" + "strings" +) + +// KeyRef identifies a non-exportable signing key held by a backend +// (TEE/TPM/Keychain). It is a stable handle (label), never the key material. +type KeyRef struct { + // Label is the backend key label/tag (e.g. "larksuite-cli-agent"). + Label string +} + +// Signer signs JWS signing inputs with a non-exportable key. +type Signer interface { + // EnsureKey returns the public key for ref, creating the key if absent. + EnsureKey(ctx context.Context, ref KeyRef) (crypto.PublicKey, error) + // PublicKey returns the public key for ref without creating it. + PublicKey(ctx context.Context, ref KeyRef) (crypto.PublicKey, error) + // Sign signs signingInput and returns a JOSE-format signature plus the JWS + // alg ("ES256"/"RS256"). Implementations apply the alg's hash and, for + // ECDSA, MUST return the fixed-width r||s form required by RFC 7518 §3.4 + // (not ASN.1 DER), because the backend (TPM/Keychain) typically yields DER. + Sign(ctx context.Context, ref KeyRef, signingInput []byte) (sig []byte, alg string, err error) +} + +// Supported JWS algorithms. +const ( + AlgES256 = "ES256" + AlgRS256 = "RS256" +) + +// DefaultKeyLabel is the backend key label lark-cli uses for its device signing +// key. One non-exportable key is created on first private_key_jwt registration +// and reused across subsequent app registrations on the same device. +const DefaultKeyLabel = "larksuite-cli-agent" + +// HardwareInfo describes the secure hardware backing a Signer, as reported by a +// HardwareProber. It is advisory/diagnostic: it tells a user whether +// private_key_jwt can use a real TEE on this device. +type HardwareInfo struct { + Backend string // backing technology, e.g. "tpm2" or "keychain" + Available bool // the hardware is present and usable for signing + VendorName string // hardware vendor/manufacturer, when known + VendorInfo string // additional vendor detail, when known + Reason string // when Available is false, a human-readable cause +} + +// HardwareProber is an optional capability a Signer may implement to report on +// the secure hardware backing it (TPM/TEE vendor and availability) WITHOUT +// creating or using a key. Probing never mutates key state. +type HardwareProber interface { + ProbeHardware(ctx context.Context) (HardwareInfo, error) +} + +// ProbeActiveHardware probes the active signer's secure hardware. ok is false +// when there is no active signer or it does not implement HardwareProber — in +// which case private_key_jwt is unsupported on this build. When ok is true, info +// reports availability and, if unavailable, info.Reason explains why. +func ProbeActiveHardware(ctx context.Context) (info HardwareInfo, ok bool, err error) { + return probeHardware(ctx, Active()) +} + +// probeHardware is the registry-independent core of ProbeActiveHardware, so it +// can be unit-tested without touching the global signer. +func probeHardware(ctx context.Context, s Signer) (HardwareInfo, bool, error) { + p, ok := s.(HardwareProber) + if !ok { + return HardwareInfo{}, false, nil + } + info, err := p.ProbeHardware(ctx) + return info, true, err +} + +// cleanProbeError renders err's message with redundant re-wraps collapsed. Some +// backends (e.g. facebookincubator/sks) wrap an error twice with the SAME "%w" +// prefix, yielding "P: P: cause"; this peels each outer layer whose only +// contribution is to repeat the prefix already present in the wrapped error, +// leaving a single "P: cause". A layer that adds genuinely new context is kept. +func cleanProbeError(err error) string { + if err == nil { + return "" + } + msg := err.Error() + for { + inner := errors.Unwrap(err) + if inner == nil { + break + } + innerMsg := inner.Error() + prefix, ok := strings.CutSuffix(msg, innerMsg) + if !ok || prefix == "" || !strings.HasPrefix(innerMsg, prefix) { + break + } + msg, err = innerMsg, inner + } + return msg +} + +// AlgForKey returns the JWS alg for a public key: EC P-256 -> ES256, RSA -> RS256. +// The signer backend chooses the key type (the macOS keychain signer uses an +// RSA-2048 key, hence RS256). +func AlgForKey(pub crypto.PublicKey) (string, error) { + switch k := pub.(type) { + case *ecdsa.PublicKey: + if k.Curve == elliptic.P256() { + return AlgES256, nil + } + return "", fmt.Errorf("keysigner: unsupported EC curve %q (only P-256/ES256)", k.Curve.Params().Name) + case *rsa.PublicKey: + return AlgRS256, nil + default: + return "", fmt.Errorf("keysigner: unsupported public key type %T", pub) + } +} + +// ecdsaDERToJOSE converts an ASN.1 DER-encoded ECDSA signature — the form most +// TEE/TPM backends emit (e.g. facebookincubator/sks marshals the TPM's r,s with +// asn1.Marshal) — into the fixed-width r||s form JWS requires for ES256 +// (RFC 7518 §3.4). byteLen is the curve coordinate size (32 for P-256), so the +// result is exactly 2*byteLen bytes with r and s each left-zero-padded. +// +// This is intentionally part of the pure-stdlib core (not a platform signer) so +// it can be unit-tested with a software key on any machine, including TPM-less CI. +func ecdsaDERToJOSE(der []byte, byteLen int) ([]byte, error) { + var sig struct{ R, S *big.Int } + rest, err := asn1.Unmarshal(der, &sig) + if err != nil { + return nil, fmt.Errorf("keysigner: parse ECDSA DER signature: %w", err) + } + if len(rest) != 0 { + return nil, fmt.Errorf("keysigner: %d trailing byte(s) after ECDSA DER signature", len(rest)) + } + if sig.R == nil || sig.S == nil || sig.R.Sign() <= 0 || sig.S.Sign() <= 0 { + return nil, fmt.Errorf("keysigner: ECDSA signature has non-positive r/s") + } + // Guard before FillBytes, which panics if the scalar does not fit in byteLen. + if sig.R.BitLen() > byteLen*8 || sig.S.BitLen() > byteLen*8 { + return nil, fmt.Errorf("keysigner: ECDSA r/s exceeds %d-byte coordinate", byteLen) + } + out := make([]byte, 2*byteLen) + sig.R.FillBytes(out[:byteLen]) + sig.S.FillBytes(out[byteLen:]) + return out, nil +} + +// EncodePublicKey marshals pub to PKIX DER and base64-encodes it (std encoding), +// matching the public-key form the registration backend binds to the app. +func EncodePublicKey(pub crypto.PublicKey) (string, error) { + der, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + return "", fmt.Errorf("keysigner: encode public key: %w", err) + } + return base64.StdEncoding.EncodeToString(der), nil +} + +// PublicKeyJWK returns the RFC 7517 JSON Web Key for pub, used to embed the +// public key in the attestation JWT's "jwk" header so the registration backend +// can bind it to the app. EC keys use base64url fixed-width coordinates +// (RFC 7518 §6.2.1); RSA keys use base64url-encoded modulus and exponent. +func PublicKeyJWK(pub crypto.PublicKey) (map[string]any, error) { + switch k := pub.(type) { + case *ecdsa.PublicKey: + if k.Curve != elliptic.P256() { + return nil, fmt.Errorf("keysigner: JWK supports EC P-256 only, got %q", k.Curve.Params().Name) + } + const coordLen = 32 // P-256 field element size + x := make([]byte, coordLen) + y := make([]byte, coordLen) + k.X.FillBytes(x) + k.Y.FillBytes(y) + return map[string]any{ + "use": "sig", + "kty": "EC", + "crv": "P-256", + "x": base64.RawURLEncoding.EncodeToString(x), + "y": base64.RawURLEncoding.EncodeToString(y), + }, nil + case *rsa.PublicKey: + return map[string]any{ + "use": "sig", + "kty": "RSA", + "n": base64.RawURLEncoding.EncodeToString(k.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes()), + }, nil + default: + return nil, fmt.Errorf("keysigner: unsupported public key type %T for JWK", pub) + } +} diff --git a/internal/keysigner/keysigner_test.go b/internal/keysigner/keysigner_test.go new file mode 100644 index 0000000000..acbda4784f --- /dev/null +++ b/internal/keysigner/keysigner_test.go @@ -0,0 +1,240 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package keysigner + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "math/big" + "reflect" + "testing" +) + +func TestAlgForKey(t *testing.T) { + ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + if alg, err := AlgForKey(ec.Public()); err != nil || alg != AlgES256 { + t.Errorf("P-256: alg=%q err=%v, want ES256/nil", alg, err) + } + + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + if alg, err := AlgForKey(rsaKey.Public()); err != nil || alg != AlgRS256 { + t.Errorf("RSA: alg=%q err=%v, want RS256/nil", alg, err) + } + + ec384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + t.Fatal(err) + } + if _, err := AlgForKey(ec384.Public()); err == nil { + t.Error("P-384: expected unsupported-curve error") + } + + if _, err := AlgForKey("not a key"); err == nil { + t.Error("string: expected unsupported-type error") + } +} + +func TestEncodePublicKeyRoundTrip(t *testing.T) { + ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + enc, err := EncodePublicKey(ec.Public()) + if err != nil { + t.Fatal(err) + } + der, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + t.Fatalf("not valid base64: %v", err) + } + pub, err := x509.ParsePKIXPublicKey(der) + if err != nil { + t.Fatalf("not valid PKIX: %v", err) + } + if !reflect.DeepEqual(pub, ec.Public()) { + t.Error("public key did not round-trip") + } +} + +func TestPublicKeyJWK_EC(t *testing.T) { + ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + jwk, err := PublicKeyJWK(ec.Public()) + if err != nil { + t.Fatal(err) + } + if jwk["kty"] != "EC" || jwk["crv"] != "P-256" { + t.Errorf("jwk = %v, want kty=EC crv=P-256", jwk) + } + if jwk["use"] != "sig" { + t.Errorf("jwk use = %v, want sig", jwk["use"]) + } + x, _ := jwk["x"].(string) + xb, err := base64.RawURLEncoding.DecodeString(x) + if err != nil || len(xb) != 32 { + t.Errorf("x = %q (decoded %d bytes), want 32-byte base64url", x, len(xb)) + } + if _, ok := jwk["y"].(string); !ok { + t.Error("jwk missing y") + } +} + +func TestPublicKeyJWK_RSA(t *testing.T) { + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + jwk, err := PublicKeyJWK(rsaKey.Public()) + if err != nil { + t.Fatal(err) + } + if jwk["kty"] != "RSA" || jwk["n"] == "" || jwk["e"] == "" { + t.Errorf("jwk = %v, want kty=RSA with n,e", jwk) + } + if jwk["use"] != "sig" { + t.Errorf("jwk use = %v, want sig", jwk["use"]) + } +} + +func TestPublicKeyJWK_UnsupportedCurve(t *testing.T) { + ec384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + t.Fatal(err) + } + if _, err := PublicKeyJWK(ec384.Public()); err == nil { + t.Error("P-384: expected error") + } +} + +func TestECDSADERToJOSE(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + // Iterate so we hit signatures whose r or s has its high bit set (ASN.1 pads + // those with a leading 0x00) and whose scalars are short (need left-zero + // padding) — verifying fixed-width conversion in both directions. + for i := 0; i < 64; i++ { + digest := sha256.Sum256([]byte{byte(i), byte(i >> 8), 'j', 'w', 't'}) + der, err := ecdsa.SignASN1(rand.Reader, key, digest[:]) + if err != nil { + t.Fatal(err) + } + jose, err := ecdsaDERToJOSE(der, 32) + if err != nil { + t.Fatalf("iter %d: %v", i, err) + } + if len(jose) != 64 { + t.Fatalf("iter %d: len(jose)=%d, want 64 (fixed-width r||s)", i, len(jose)) + } + r := new(big.Int).SetBytes(jose[:32]) + s := new(big.Int).SetBytes(jose[32:]) + if !ecdsa.Verify(&key.PublicKey, digest[:], r, s) { + t.Fatalf("iter %d: converted r||s did not verify against the public key", i) + } + } +} + +func TestECDSADERToJOSE_Errors(t *testing.T) { + if _, err := ecdsaDERToJOSE([]byte{0x01, 0x02, 0x03}, 32); err == nil { + t.Error("garbage DER: expected error") + } + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256([]byte("trailing")) + der, err := ecdsa.SignASN1(rand.Reader, key, digest[:]) + if err != nil { + t.Fatal(err) + } + if _, err := ecdsaDERToJOSE(append(der, 0x00), 32); err == nil { + t.Error("DER with trailing byte: expected error") + } +} + +type stubSigner struct{} + +func (stubSigner) EnsureKey(context.Context, KeyRef) (crypto.PublicKey, error) { return nil, nil } +func (stubSigner) PublicKey(context.Context, KeyRef) (crypto.PublicKey, error) { return nil, nil } +func (stubSigner) Sign(context.Context, KeyRef, []byte) ([]byte, string, error) { return nil, "", nil } + +func TestCleanProbeError(t *testing.T) { + cause := errors.New("open /dev/tpmrm0: permission denied") + const p = "sks: error fetching Secure Hardware Vendor Data: " + + // sks double-wraps with the same %w prefix → collapse to a single prefix. + doubled := fmt.Errorf(p+"%w", fmt.Errorf(p+"%w", cause)) + if got, want := cleanProbeError(doubled), p+cause.Error(); got != want { + t.Errorf("doubled: got %q, want %q", got, want) + } + // Triple wrap collapses too. + if got, want := cleanProbeError(fmt.Errorf(p+"%w", doubled)), p+cause.Error(); got != want { + t.Errorf("tripled: got %q, want %q", got, want) + } + // A layer adding genuinely new context is preserved. + if got, want := cleanProbeError(fmt.Errorf("load: %w", cause)), "load: "+cause.Error(); got != want { + t.Errorf("distinct prefix: got %q, want %q", got, want) + } + // nil and unwrapped-leaf cases. + if got := cleanProbeError(nil); got != "" { + t.Errorf("nil: got %q, want empty", got) + } + if got := cleanProbeError(cause); got != cause.Error() { + t.Errorf("leaf: got %q, want %q", got, cause.Error()) + } +} + +type proberSigner struct { + stubSigner + info HardwareInfo +} + +func (p proberSigner) ProbeHardware(context.Context) (HardwareInfo, error) { return p.info, nil } + +func TestProbeHardware(t *testing.T) { + // nil signer and a signer that does not implement HardwareProber both yield ok=false. + if _, ok, _ := probeHardware(context.Background(), nil); ok { + t.Error("nil signer: ok should be false") + } + if _, ok, _ := probeHardware(context.Background(), stubSigner{}); ok { + t.Error("non-prober signer: ok should be false") + } + + want := HardwareInfo{Backend: "tpm2", Available: true, VendorName: "ACME"} + info, ok, err := probeHardware(context.Background(), proberSigner{info: want}) + if err != nil || !ok { + t.Fatalf("prober: ok=%v err=%v, want true/nil", ok, err) + } + if info != want { + t.Errorf("info = %+v, want %+v", info, want) + } +} + +func TestRegistry(t *testing.T) { + if Active() != nil { + t.Skip("a signer is already registered in this build") + } + Register(stubSigner{}) + if _, ok := Active().(stubSigner); !ok { + t.Error("Active did not return the registered signer") + } +} diff --git a/internal/keysigner/registry.go b/internal/keysigner/registry.go new file mode 100644 index 0000000000..010582dc92 --- /dev/null +++ b/internal/keysigner/registry.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package keysigner + +import "sync" + +var ( + mu sync.RWMutex + active Signer +) + +// Register sets the active Signer. It is typically called from the init() of a +// build-tagged or extension package that provides the platform TEE/Keychain +// implementation. The last registration wins (one backend per platform). +func Register(s Signer) { + mu.Lock() + defer mu.Unlock() + active = s +} + +// Active returns the registered Signer, or nil if none is available — in which +// case private_key_jwt is unsupported on this build and only client_secret auth +// can be used. +func Active() Signer { + mu.RLock() + defer mu.RUnlock() + return active +} diff --git a/internal/keysigner/signer_keychain_darwin.go b/internal/keysigner/signer_keychain_darwin.go new file mode 100644 index 0000000000..58eb3e6776 --- /dev/null +++ b/internal/keysigner/signer_keychain_darwin.go @@ -0,0 +1,760 @@ +//go:build darwin + +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// macOS non-exportable Keychain signer (compiled into every darwin build). +// +// It does NOT use the Secure Enclave / hardware TEE (which would require +// code-signing entitlements that are unfriendly to open source). Instead it +// generates an RSA-2048 key directly inside a dedicated app keychain. The +// private key is permanent, sensitive, and non-extractable; it is never present +// in Go memory or a temporary file. Its access list trusts only the creating +// application by default. Signing is RSASSA-PKCS1v15-SHA256 (RS256). +// +// Security and CoreFoundation are called through runtime FFI +// (github.com/ebitengine/purego). Key generation and signing stay inside the OS +// APIs while the binary remains CGO-free and can be cross-compiled for darwin. +// +// Build with: go build (cgo-free; compiled into every darwin build, no tag) +package keysigner + +import ( + "bytes" + "context" + "crypto" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "sync" + "unsafe" + + "github.com/ebitengine/purego" + "github.com/larksuite/cli/internal/vfs" +) + +// ---- Security / CoreFoundation runtime bindings (purego, no cgo) ---- + +const ( + cfFrameworkPath = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation" + secFrameworkPath = "/System/Library/Frameworks/Security.framework/Security" + + // kCFStringEncodingUTF8 (CFStringBuiltInEncodings). + cfStringEncodingUTF8 = 0x08000100 + + // OSStatus values. + errSecSuccess = 0 + + // Legacy Security.framework key-generation values from cssmtype.h. The + // legacy API is used because it can target a dedicated file keychain and set + // both a non-extractable attribute and an application-only SecAccess ACL. + cssmAlgIDRSA = 42 + cssmKeyUseSign = 0x00000004 + cssmKeyUseVerify = 0x00000008 + cssmKeyAttrPermanent = 0x00000001 + cssmKeyAttrSensitive = 0x00000008 + cssmKeyAttrExtractable = 0x00000020 + + publicKeyAttributes = cssmKeyAttrPermanent | cssmKeyAttrExtractable + privateKeyAttributes = cssmKeyAttrPermanent | cssmKeyAttrSensitive +) + +var ( + ffiOnce sync.Once + ffiErr error + + cfDataCreate func(alloc uintptr, bytes *byte, length int) uintptr + cfDataGetLength func(d uintptr) int + cfDataGetBytePtr func(d uintptr) unsafe.Pointer + cfStringCreate func(alloc uintptr, cstr *byte, encoding uint32) uintptr + cfArrayCreate func(alloc uintptr, values *uintptr, numValues int, cb uintptr) uintptr + cfDictCreateMutable func(alloc uintptr, capacity int, keyCB, valCB uintptr) uintptr + cfDictSetValue func(dict, key, val uintptr) + cfRelease func(ref uintptr) + cfErrorGetCode func(e uintptr) int + dlsymDataPointer func(handle uintptr, name string) *uintptr + secKeychainCreate func(path *byte, passwordLength uint32, password unsafe.Pointer, promptUser uint8, initialAccess uintptr, out *uintptr) int32 + secKeychainOpen func(path *byte, out *uintptr) int32 + secKeychainUnlock func(keychain uintptr, passwordLength uint32, password unsafe.Pointer, usePassword uint8) int32 + secAccessCreate func(descriptor, trustedList uintptr, out *uintptr) int32 + secKeyCreatePair func(keychain uintptr, algorithm, keySize uint32, contextHandle uint64, publicKeyUsage, publicKeyAttr, privateKeyUsage, privateKeyAttr uint32, initialAccess uintptr, publicKey, privateKey *uintptr) int32 + secKeyCopyExternal func(key uintptr, errOut *uintptr) uintptr + secKeychainItemDelete func(item uintptr) int32 + secItemCopyMatching func(query uintptr, result *uintptr) int32 + secItemUpdate func(query, attrs uintptr) int32 + secKeyCreateSignature func(key, algo, data uintptr, errOut *uintptr) uintptr + + // CFTypeRef data-symbol constants (deref to obtain the held ref value). + kSecClass uintptr + kSecClassKey uintptr + kSecAttrKeyClass uintptr + kSecAttrKeyClassPrivate uintptr + kSecAttrKeyType uintptr + kSecAttrKeyTypeRSA uintptr + kSecAttrApplicationLabel uintptr + kSecReturnRef uintptr + kSecMatchSearchList uintptr + kSecAttrLabel uintptr + kCFBooleanTrue uintptr + algRSAPKCS1SHA256 uintptr + + // Struct-symbol constants (passed BY ADDRESS, not dereferenced). + cbTypeArray uintptr + cbDictKey uintptr + cbDictValue uintptr +) + +// loadFFI resolves the framework functions and constants once. Any failure +// (framework missing, symbol absent) is returned to every caller so signing +// fails cleanly rather than crashing. +func loadFFI() error { + ffiOnce.Do(func() { + // RegisterLibFunc panics when a symbol is unavailable. Convert that into + // the same stable availability error as dlopen/dlsym failures so doctor + // and auth commands never crash on a future macOS without a legacy symbol. + defer func() { + if recovered := recover(); recovered != nil { + ffiErr = fmt.Errorf("keysigner: load Security framework bindings: %v", recovered) + } + }() + cf, err := purego.Dlopen(cfFrameworkPath, purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err != nil { + ffiErr = fmt.Errorf("keysigner: dlopen CoreFoundation: %w", err) + return + } + sec, err := purego.Dlopen(secFrameworkPath, purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err != nil { + ffiErr = fmt.Errorf("keysigner: dlopen Security: %w", err) + return + } + + purego.RegisterLibFunc(&cfDataCreate, cf, "CFDataCreate") + purego.RegisterLibFunc(&cfDataGetLength, cf, "CFDataGetLength") + purego.RegisterLibFunc(&cfDataGetBytePtr, cf, "CFDataGetBytePtr") + purego.RegisterLibFunc(&cfStringCreate, cf, "CFStringCreateWithCString") + purego.RegisterLibFunc(&cfArrayCreate, cf, "CFArrayCreate") + purego.RegisterLibFunc(&cfDictCreateMutable, cf, "CFDictionaryCreateMutable") + purego.RegisterLibFunc(&cfDictSetValue, cf, "CFDictionarySetValue") + purego.RegisterLibFunc(&cfRelease, cf, "CFRelease") + purego.RegisterLibFunc(&cfErrorGetCode, cf, "CFErrorGetCode") + // purego.Dlsym exposes symbol addresses as uintptr. Bind dlsym with a + // pointer return for data symbols so reading exported CFTypeRef variables + // never performs a uintptr-to-pointer conversion in Go. + purego.RegisterLibFunc(&dlsymDataPointer, purego.RTLD_DEFAULT, "dlsym") + purego.RegisterLibFunc(&secKeychainCreate, sec, "SecKeychainCreate") + purego.RegisterLibFunc(&secKeychainOpen, sec, "SecKeychainOpen") + purego.RegisterLibFunc(&secKeychainUnlock, sec, "SecKeychainUnlock") + purego.RegisterLibFunc(&secAccessCreate, sec, "SecAccessCreate") + purego.RegisterLibFunc(&secKeyCreatePair, sec, "SecKeyCreatePair") + purego.RegisterLibFunc(&secKeyCopyExternal, sec, "SecKeyCopyExternalRepresentation") + purego.RegisterLibFunc(&secKeychainItemDelete, sec, "SecKeychainItemDelete") + purego.RegisterLibFunc(&secItemCopyMatching, sec, "SecItemCopyMatching") + purego.RegisterLibFunc(&secItemUpdate, sec, "SecItemUpdate") + purego.RegisterLibFunc(&secKeyCreateSignature, sec, "SecKeyCreateSignature") + + // CFStringRef/CFBooleanRef constants: Dlsym gives the address of the + // exported variable; deref once to read the ref it holds. + derefs := []struct { + dst *uintptr + handle uintptr + name string + }{ + {&kSecClass, sec, "kSecClass"}, + {&kSecClassKey, sec, "kSecClassKey"}, + {&kSecAttrKeyClass, sec, "kSecAttrKeyClass"}, + {&kSecAttrKeyClassPrivate, sec, "kSecAttrKeyClassPrivate"}, + {&kSecAttrKeyType, sec, "kSecAttrKeyType"}, + {&kSecAttrKeyTypeRSA, sec, "kSecAttrKeyTypeRSA"}, + {&kSecAttrApplicationLabel, sec, "kSecAttrApplicationLabel"}, + {&kSecReturnRef, sec, "kSecReturnRef"}, + {&kSecMatchSearchList, sec, "kSecMatchSearchList"}, + {&kSecAttrLabel, sec, "kSecAttrLabel"}, + {&kCFBooleanTrue, cf, "kCFBooleanTrue"}, + {&algRSAPKCS1SHA256, sec, "kSecKeyAlgorithmRSASignatureDigestPKCS1v15SHA256"}, + } + for _, d := range derefs { + sym := dlsymDataPointer(d.handle, d.name) + if sym == nil { + ffiErr = fmt.Errorf("keysigner: dlsym %s returned zero address", d.name) + return + } + *d.dst = *sym + if *d.dst == 0 { + ffiErr = fmt.Errorf("keysigner: data symbol %s contains a zero reference", d.name) + return + } + } + + // Callback structs are passed by address (no deref). + addrs := []struct { + dst *uintptr + handle uintptr + name string + }{ + {&cbTypeArray, cf, "kCFTypeArrayCallBacks"}, + {&cbDictKey, cf, "kCFTypeDictionaryKeyCallBacks"}, + {&cbDictValue, cf, "kCFTypeDictionaryValueCallBacks"}, + } + for _, a := range addrs { + sym, e := purego.Dlsym(a.handle, a.name) + if e != nil { + ffiErr = fmt.Errorf("keysigner: dlsym %s: %w", a.name, e) + return + } + if sym == 0 { + ffiErr = fmt.Errorf("keysigner: dlsym %s returned zero address", a.name) + return + } + *a.dst = sym + } + }) + return ffiErr +} + +// cstr returns a pointer to a NUL-terminated copy of s. The backing array stays +// alive while the returned pointer is reachable. +func cstr(s string) *byte { + b := append([]byte(s), 0) + return &b[0] +} + +// cfBytes wraps Go bytes in a CFData (CFDataCreate copies the bytes). Caller +// releases the returned CFDataRef. +func cfBytes(b []byte) uintptr { + var p *byte + if len(b) > 0 { + p = &b[0] + } + d := cfDataCreate(0, p, len(b)) + runtime.KeepAlive(b) + return d +} + +// keychainSearchArray opens the dedicated keychain file and wraps it in a +// CFArray for kSecMatchSearchList. Caller releases the returned array. +// +// NOTE: SecKeychainOpen / the file-based keychain are deprecated by Apple in +// favor of the data-protection keychain. They still function on current macOS; +// migrating off them is tracked separately and is independent of the cgo→purego +// change (the original cgo version used the same APIs). +func keychainSearchArray(keychainPath string) (uintptr, error) { + var kc uintptr + if st := secKeychainOpen(cstr(keychainPath), &kc); st != errSecSuccess { + return 0, keychainError("open keychain", int(st)) + } + vals := [1]uintptr{kc} + arr := cfArrayCreate(0, &vals[0], 1, cbTypeArray) + cfRelease(kc) // the array retains it + if arr == 0 { + return 0, fmt.Errorf("keysigner: CFArrayCreate(search list) failed") + } + return arr, nil +} + +// findPrivateKey locates the non-extractable private key by its application +// label within the dedicated keychain. Caller releases the returned SecKeyRef. +func findPrivateKey(appLabel []byte, keychainPath string) (uintptr, error) { + search, err := keychainSearchArray(keychainPath) + if err != nil { + return 0, err + } + defer cfRelease(search) + + labelData := cfBytes(appLabel) + defer cfRelease(labelData) + + q := cfDictCreateMutable(0, 0, cbDictKey, cbDictValue) + if q == 0 { + return 0, fmt.Errorf("keysigner: CFDictionaryCreateMutable(query) failed") + } + defer cfRelease(q) + cfDictSetValue(q, kSecClass, kSecClassKey) + cfDictSetValue(q, kSecAttrKeyClass, kSecAttrKeyClassPrivate) + cfDictSetValue(q, kSecAttrKeyType, kSecAttrKeyTypeRSA) + cfDictSetValue(q, kSecAttrApplicationLabel, labelData) + cfDictSetValue(q, kSecReturnRef, kCFBooleanTrue) + cfDictSetValue(q, kSecMatchSearchList, search) + + var keyRef uintptr + if st := secItemCopyMatching(q, &keyRef); st != errSecSuccess { + return 0, keychainError("find private key", int(st)) + } + return keyRef, nil +} + +// These seams keep lifecycle tests hermetic. Production calls Security.framework +// directly, so the generated keychain password never appears in process argv. +var ( + createKeychainFile = createKeychainFileFFI + unlockKeychainFile = unlockKeychainFileFFI +) + +// keychainSigner implements Signer using a macOS non-exportable Keychain key. +type keychainSigner struct{} + +func init() { Register(keychainSigner{}) } + +// ProbeHardware reports the macOS Keychain backend backing this signer. The +// keychain signer is compiled into every darwin build and needs no special +// hardware, so it reports available whenever its framework bindings load. +// It performs no key access, so it never prompts. Implementing HardwareProber +// is what lets `doctor` report the signer as present rather than treating the +// (prober-less) signer as "no platform signer in this build". +func (keychainSigner) ProbeHardware(_ context.Context) (HardwareInfo, error) { + info := HardwareInfo{Backend: "keychain", VendorName: "macOS Keychain"} + // A missing framework or symbol is a status (Available=false via Reason), + // not a probe error. Loading symbols does not touch the keychain or prompt. + if err := loadFFI(); err != nil { + info.Reason = err.Error() + return info, nil //nolint:nilerr // absence is reported via Reason, not as an error + } + info.Available = true + return info, nil +} + +func (keychainSigner) EnsureKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) { + if md, err := readKeyMetadata(ref.Label); err == nil { + return decodePublicKey(md.PublicKey) + } else if !os.IsNotExist(err) { + return nil, err + } + return createKeychainKey(ref.Label) +} + +func (keychainSigner) PublicKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) { + md, err := readKeyMetadata(ref.Label) + if err != nil { + return nil, err + } + return decodePublicKey(md.PublicKey) +} + +func (keychainSigner) Sign(_ context.Context, ref KeyRef, signingInput []byte) ([]byte, string, error) { + if err := loadFFI(); err != nil { + return nil, "", err + } + md, err := readKeyMetadata(ref.Label) + if err != nil { + return nil, "", err + } + appLabel, err := hex.DecodeString(md.AppLabel) + if err != nil { + return nil, "", fmt.Errorf("keysigner: decode app label: %w", err) + } + if len(appLabel) == 0 { + // Guard the &appLabel[0] pointer below against corrupted metadata. + return nil, "", fmt.Errorf("keysigner: key metadata for %q has empty app label", ref.Label) + } + keychain, err := ensureKeychain() + if err != nil { + return nil, "", err + } + + keyRef, err := findPrivateKey(appLabel, keychain) + if err != nil { + return nil, "", err + } + defer cfRelease(keyRef) + + digest := sha256.Sum256(signingInput) + digestData := cfBytes(digest[:]) + defer cfRelease(digestData) + + var errRef uintptr + sigRef := secKeyCreateSignature(keyRef, algRSAPKCS1SHA256, digestData, &errRef) + if sigRef == 0 { + code := 0 + if errRef != 0 { + code = cfErrorGetCode(errRef) + cfRelease(errRef) + } + return nil, "", fmt.Errorf("keysigner: SecKeyCreateSignature failed (CFError %d)", code) + } + defer cfRelease(sigRef) + + n := cfDataGetLength(sigRef) + bp := cfDataGetBytePtr(sigRef) + out := make([]byte, n) + copy(out, unsafe.Slice((*byte)(bp), n)) + // RS256: the SecKey PKCS1v15-SHA256 signature is the JOSE signature as-is. + return out, AlgRS256, nil +} + +// keyMetadata records the public key + the keychain application-label used to +// locate the non-extractable private key. +type keyMetadata struct { + PublicKey string `json:"public_key"` // PKIX DER, std base64 (see EncodePublicKey) + AppLabel string `json:"app_label"` // hex(sha1(PKCS1 public key)) +} + +func createKeychainKey(label string) (crypto.PublicKey, error) { + metadataPath, err := keyMetadataPath(label) + if err != nil { + return nil, err + } + if err := loadFFI(); err != nil { + return nil, err + } + keychain, err := ensureKeychain() + if err != nil { + return nil, err + } + + var keychainRef uintptr + if st := secKeychainOpen(cstr(keychain), &keychainRef); st != errSecSuccess { + return nil, keychainError("open keychain for key generation", int(st)) + } + if keychainRef == 0 { + return nil, fmt.Errorf("keysigner: open keychain for key generation returned an empty reference") + } + defer cfRelease(keychainRef) + + descriptor := cfStringCreate(0, cstr(label), cfStringEncodingUTF8) + if descriptor == 0 { + return nil, fmt.Errorf("keysigner: create key access descriptor failed") + } + defer cfRelease(descriptor) + + // A nil trusted list means only this application is trusted without a + // confirmation dialog. This is intentionally stricter than security(1) -A. + var access uintptr + if st := secAccessCreate(descriptor, 0, &access); st != errSecSuccess { + return nil, keychainError("create key access policy", int(st)) + } + if access == 0 { + return nil, fmt.Errorf("keysigner: create key access policy returned an empty reference") + } + defer cfRelease(access) + + var publicKeyRef, privateKeyRef uintptr + status := secKeyCreatePair( + keychainRef, + cssmAlgIDRSA, + 2048, + 0, + cssmKeyUseVerify, + publicKeyAttributes, + cssmKeyUseSign, + privateKeyAttributes, + access, + &publicKeyRef, + &privateKeyRef, + ) + deleteAndRelease := func(keyRef uintptr) { + if keyRef != 0 { + _ = secKeychainItemDelete(keyRef) + cfRelease(keyRef) + } + } + if status != errSecSuccess { + deleteAndRelease(privateKeyRef) + deleteAndRelease(publicKeyRef) + return nil, keychainError("generate non-extractable RSA key", int(status)) + } + if publicKeyRef == 0 || privateKeyRef == 0 { + deleteAndRelease(privateKeyRef) + deleteAndRelease(publicKeyRef) + return nil, fmt.Errorf("keysigner: key generation returned an empty key reference") + } + defer cfRelease(publicKeyRef) + defer cfRelease(privateKeyRef) + + committed := false + defer func() { + if !committed { + _ = secKeychainItemDelete(privateKeyRef) + _ = secKeychainItemDelete(publicKeyRef) + } + }() + + var exportErr uintptr + publicDERRef := secKeyCopyExternal(publicKeyRef, &exportErr) + if publicDERRef == 0 { + code := 0 + if exportErr != 0 { + code = cfErrorGetCode(exportErr) + cfRelease(exportErr) + } + return nil, fmt.Errorf("keysigner: export public key failed (CFError %d)", code) + } + defer cfRelease(publicDERRef) + publicDERLength := cfDataGetLength(publicDERRef) + publicDERPointer := cfDataGetBytePtr(publicDERRef) + if publicDERLength <= 0 || publicDERPointer == nil { + return nil, fmt.Errorf("keysigner: exported public key is empty") + } + publicDER := make([]byte, publicDERLength) + copy(publicDER, unsafe.Slice((*byte)(publicDERPointer), publicDERLength)) + publicKey, err := x509.ParsePKCS1PublicKey(publicDER) + if err != nil { + return nil, fmt.Errorf("keysigner: parse generated RSA public key: %w", err) + } + appLabel := sha1.Sum(publicDER) + + if err := setKeychainKeyLabel(appLabel[:], keychain, label); err != nil { + return nil, err + } + + encodedPub, err := EncodePublicKey(publicKey) + if err != nil { + return nil, err + } + if err := writeKeyMetadata(metadataPath, keyMetadata{PublicKey: encodedPub, AppLabel: hex.EncodeToString(appLabel[:])}); err != nil { + return nil, err + } + committed = true + return publicKey, nil +} + +func setKeychainKeyLabel(appLabel []byte, keychain, label string) error { + if err := loadFFI(); err != nil { + return err + } + search, err := keychainSearchArray(keychain) + if err != nil { + return err + } + defer cfRelease(search) + + labelData := cfBytes(appLabel) + defer cfRelease(labelData) + + q := cfDictCreateMutable(0, 0, cbDictKey, cbDictValue) + if q == 0 { + return fmt.Errorf("keysigner: CFDictionaryCreateMutable(query) failed") + } + defer cfRelease(q) + cfDictSetValue(q, kSecClass, kSecClassKey) + cfDictSetValue(q, kSecAttrKeyClass, kSecAttrKeyClassPrivate) + cfDictSetValue(q, kSecAttrKeyType, kSecAttrKeyTypeRSA) + cfDictSetValue(q, kSecAttrApplicationLabel, labelData) + cfDictSetValue(q, kSecMatchSearchList, search) + + cfLabel := cfStringCreate(0, cstr(label), cfStringEncodingUTF8) + if cfLabel == 0 { + return fmt.Errorf("keysigner: CFStringCreateWithCString failed") + } + defer cfRelease(cfLabel) + attrs := cfDictCreateMutable(0, 0, cbDictKey, cbDictValue) + if attrs == 0 { + return fmt.Errorf("keysigner: CFDictionaryCreateMutable(attrs) failed") + } + defer cfRelease(attrs) + cfDictSetValue(attrs, kSecAttrLabel, cfLabel) + + if st := secItemUpdate(q, attrs); st != errSecSuccess { + return keychainError("set keychain key label", int(st)) + } + return nil +} + +func decodePublicKey(encoded string) (crypto.PublicKey, error) { + der, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("keysigner: decode public key: %w", err) + } + return x509.ParsePKIXPublicKey(der) +} + +func readKeyMetadata(label string) (*keyMetadata, error) { + path, err := keyMetadataPath(label) + if err != nil { + return nil, err + } + data, err := vfs.ReadFile(path) + if err != nil { + return nil, err // preserves os.ErrNotExist for EnsureKey + } + var md keyMetadata + if err := json.Unmarshal(data, &md); err != nil { + return nil, fmt.Errorf("keysigner: parse key metadata: %w", err) + } + return &md, nil +} + +func writeKeyMetadata(path string, md keyMetadata) error { + if err := vfs.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + data, err := json.MarshalIndent(md, "", " ") + if err != nil { + return err + } + return vfs.WriteFile(path, data, 0600) +} + +func ensureKeychain() (string, error) { + keychainPath, err := keychainFilePath() + if err != nil { + return "", err + } + password, err := keychainPassword() + if err != nil { + return "", err + } + defer clear(password) + if _, err := vfs.Stat(keychainPath); err != nil { + if !os.IsNotExist(err) { + return "", fmt.Errorf("keysigner: stat keychain: %w", err) + } + if err := vfs.MkdirAll(filepath.Dir(keychainPath), 0700); err != nil { + return "", err + } + if err := createKeychainFile(keychainPath, password); err != nil { + return "", err + } + } + + // A file keychain can be locked after logout, reboot, an idle interval, or an + // explicit `security lock-keychain`. SecKeychainOpen may still succeed while + // it is locked; the failure then appears later at signing time (commonly as + // SecKeyCreateSignature CFError -128). Always unlock the dedicated keychain + // before returning it, including when the file already existed. + if err := unlockKeychainFile(keychainPath, password); err != nil { + return "", err + } + return keychainPath, nil +} + +func createKeychainFileFFI(path string, password []byte) error { + if err := loadFFI(); err != nil { + return err + } + pathBytes := append([]byte(path), 0) + var keychain uintptr + status := secKeychainCreate( + &pathBytes[0], + uint32(len(password)), + byteSlicePointer(password), + 0, // promptUser=false: unattended creation must never display UI. + 0, // initialAccess is ignored; Apple documents passing NULL. + &keychain, + ) + runtime.KeepAlive(pathBytes) + runtime.KeepAlive(password) + if status != errSecSuccess { + return keychainError("create keychain", int(status)) + } + if keychain == 0 { + return fmt.Errorf("keysigner: create keychain returned an empty reference") + } + defer cfRelease(keychain) + + // Keep the system's default lock policy. ensureKeychain explicitly unlocks + // this dedicated keychain with its generated password before every use, so + // changing settings here would be unnecessary and could trigger system UI. + return nil +} + +func unlockKeychainFileFFI(path string, password []byte) error { + if err := loadFFI(); err != nil { + return err + } + pathBytes := append([]byte(path), 0) + var keychain uintptr + status := secKeychainOpen(&pathBytes[0], &keychain) + runtime.KeepAlive(pathBytes) + if status != errSecSuccess { + return keychainError("open keychain for unlock", int(status)) + } + if keychain == 0 { + return fmt.Errorf("keysigner: open keychain for unlock returned an empty reference") + } + defer cfRelease(keychain) + + status = secKeychainUnlock(keychain, uint32(len(password)), byteSlicePointer(password), 1) + runtime.KeepAlive(password) + if status != errSecSuccess { + return keychainError("unlock keychain", int(status)) + } + return nil +} + +func byteSlicePointer(data []byte) unsafe.Pointer { + if len(data) == 0 { + return nil + } + return unsafe.Pointer(&data[0]) +} + +func keysignerDir() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("keysigner: resolve config dir: %w", err) + } + return filepath.Join(configDir, "lark-cli", "keysigner"), nil +} + +func keychainFilePath() (string, error) { + dir, err := keysignerDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "lark-cli.keychain"), nil +} + +func keychainPassword() ([]byte, error) { + dir, err := keysignerDir() + if err != nil { + return nil, err + } + path := filepath.Join(dir, "keychain.pass") + if data, err := vfs.ReadFile(path); err == nil { + defer clear(data) + if pw := bytes.TrimSpace(data); len(pw) != 0 { + return append([]byte(nil), pw...), nil + } + return nil, fmt.Errorf("keysigner: empty keychain password") + } else if !os.IsNotExist(err) { + return nil, err + } + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return nil, err + } + defer clear(buf) + pw := make([]byte, hex.EncodedLen(len(buf))) + hex.Encode(pw, buf) + if err := vfs.MkdirAll(filepath.Dir(path), 0700); err != nil { + clear(pw) + return nil, err + } + stored := append(append([]byte(nil), pw...), '\n') + if err := vfs.WriteFile(path, stored, 0600); err != nil { + clear(stored) + clear(pw) + return nil, err + } + clear(stored) + return pw, nil +} + +func keyMetadataPath(label string) (string, error) { + dir, err := keysignerDir() + if err != nil { + return "", err + } + id := sha256.Sum256([]byte(label)) + return filepath.Join(dir, "keys", hex.EncodeToString(id[:])+".json"), nil +} + +func keychainError(operation string, status int) error { + switch status { + case -25299: + return fmt.Errorf("keysigner: %s: key already exists", operation) + case -25300: + return fmt.Errorf("keysigner: %s: key not found", operation) + case -2: + return fmt.Errorf("keysigner: %s: allocation failed", operation) + default: + return fmt.Errorf("keysigner: %s: Security framework status %d", operation, status) + } +} diff --git a/internal/keysigner/signer_keychain_darwin_test.go b/internal/keysigner/signer_keychain_darwin_test.go new file mode 100644 index 0000000000..7911704b73 --- /dev/null +++ b/internal/keysigner/signer_keychain_darwin_test.go @@ -0,0 +1,282 @@ +//go:build darwin + +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package keysigner + +import ( + "context" + "crypto" + "crypto/rsa" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// TestKeychainSignerRegistered confirms every Darwin build self-registers the +// signer (init → Register), so keysigner.Active() is non-nil. No keychain access. +func TestKeychainSignerRegistered(t *testing.T) { + if _, ok := Active().(keychainSigner); !ok { + t.Fatalf("Active() = %T, want keychainSigner (Darwin build must self-register)", Active()) + } +} + +func TestKeychainFFIBindings(t *testing.T) { + if err := loadFFI(); err != nil { + t.Fatalf("loadFFI: %v", err) + } + if secKeychainCreate == nil || secKeychainOpen == nil || secKeychainUnlock == nil || + secAccessCreate == nil || secKeyCreatePair == nil || secKeyCopyExternal == nil || secKeychainItemDelete == nil { + t.Fatal("one or more keychain functions were not registered") + } +} + +func TestPrivateKeyGenerationAttributesAreNonExtractable(t *testing.T) { + if privateKeyAttributes&cssmKeyAttrPermanent == 0 { + t.Fatal("private key must be stored permanently in the dedicated keychain") + } + if privateKeyAttributes&cssmKeyAttrSensitive == 0 { + t.Fatal("private key must be marked sensitive") + } + if privateKeyAttributes&cssmKeyAttrExtractable != 0 { + t.Fatal("private key must not be extractable") + } + if publicKeyAttributes&cssmKeyAttrExtractable == 0 { + t.Fatal("public key must remain exportable") + } +} + +// TestEnsureKeychainUnlocksExistingKeychain covers the recovery path for a +// dedicated file keychain that was created in an earlier process and has since +// become locked. No real Security.framework call is executed. +func TestEnsureKeychainUnlocksExistingKeychain(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + dir := filepath.Join(home, "Library", "Application Support", "lark-cli", "keysigner") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + keychainPath := filepath.Join(dir, "lark-cli.keychain") + if err := os.WriteFile(keychainPath, []byte("not-a-real-keychain"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "keychain.pass"), []byte("test-password\n"), 0600); err != nil { + t.Fatal(err) + } + + previousCreate := createKeychainFile + previousUnlock := unlockKeychainFile + createKeychainFile = func(string, []byte) error { + t.Fatal("createKeychainFile called for an existing keychain") + return nil + } + type unlockCall struct { + path string + password []byte + } + var calls []unlockCall + var borrowedPassword []byte + unlockKeychainFile = func(path string, password []byte) error { + borrowedPassword = password + calls = append(calls, unlockCall{path: path, password: append([]byte(nil), password...)}) + return nil + } + t.Cleanup(func() { + createKeychainFile = previousCreate + unlockKeychainFile = previousUnlock + }) + + got, err := ensureKeychain() + if err != nil { + t.Fatalf("ensureKeychain: %v", err) + } + if got != keychainPath { + t.Fatalf("ensureKeychain path = %q, want %q", got, keychainPath) + } + want := []unlockCall{{path: keychainPath, password: []byte("test-password")}} + if !reflect.DeepEqual(calls, want) { + t.Fatalf("unlock calls = %#v, want %#v", calls, want) + } + for i, value := range borrowedPassword { + if value != 0 { + t.Fatalf("borrowed password byte %d was not cleared after use", i) + } + } +} + +func TestEnsureKeychainNewKeychainStillUnlocksAfterCreation(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + type lifecycleCall struct { + op string + path string + password []byte + } + var calls []lifecycleCall + previousCreate := createKeychainFile + previousUnlock := unlockKeychainFile + createKeychainFile = func(path string, password []byte) error { + calls = append(calls, lifecycleCall{op: "create", path: path, password: append([]byte(nil), password...)}) + return nil + } + unlockKeychainFile = func(path string, password []byte) error { + calls = append(calls, lifecycleCall{op: "unlock", path: path, password: append([]byte(nil), password...)}) + return nil + } + t.Cleanup(func() { + createKeychainFile = previousCreate + unlockKeychainFile = previousUnlock + }) + + keychainPath, err := ensureKeychain() + if err != nil { + t.Fatalf("ensureKeychain: %v", err) + } + if len(calls) != 2 || calls[0].op != "create" || calls[1].op != "unlock" { + t.Fatalf("lifecycle calls = %#v, want create then unlock", calls) + } + for _, call := range calls { + if call.path != keychainPath || len(call.password) != 64 { + t.Fatalf("lifecycle call = %#v, want path %q and generated 64-byte password", call, keychainPath) + } + } + if !reflect.DeepEqual(calls[0].password, calls[1].password) { + t.Fatal("create and unlock did not receive the same generated password") + } +} + +func TestEnsureKeychainExistingUnlockErrorIsReturned(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + dir := filepath.Join(home, "Library", "Application Support", "lark-cli", "keysigner") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "lark-cli.keychain"), []byte("not-a-real-keychain"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "keychain.pass"), []byte("test-password\n"), 0600); err != nil { + t.Fatal(err) + } + + previousCreate := createKeychainFile + previousUnlock := unlockKeychainFile + createKeychainFile = func(string, []byte) error { + t.Fatal("createKeychainFile called for an existing keychain") + return nil + } + unlockKeychainFile = func(string, []byte) error { + return errors.New("keysigner: unlock keychain: Security framework status -25293") + } + t.Cleanup(func() { + createKeychainFile = previousCreate + unlockKeychainFile = previousUnlock + }) + + _, err := ensureKeychain() + if err == nil { + t.Fatal("ensureKeychain returned nil error") + } + if got := err.Error(); !strings.Contains(got, "unlock keychain") || !strings.Contains(got, "-25293") { + t.Fatalf("ensureKeychain error = %q", got) + } +} + +func TestEnsureKeychainCreateErrorStopsBeforeUnlock(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + previousCreate := createKeychainFile + previousUnlock := unlockKeychainFile + createKeychainFile = func(string, []byte) error { + return errors.New("keysigner: create keychain: Security framework status -50") + } + unlockKeychainFile = func(string, []byte) error { + t.Fatal("unlockKeychainFile called after creation failed") + return nil + } + t.Cleanup(func() { + createKeychainFile = previousCreate + unlockKeychainFile = previousUnlock + }) + + _, err := ensureKeychain() + if err == nil || !strings.Contains(err.Error(), "create keychain") || !strings.Contains(err.Error(), "-50") { + t.Fatalf("ensureKeychain error = %v", err) + } +} + +// TestKeychainSignerRoundTrip creates a real non-extractable RSA key, signs, and +// verifies RS256 against the returned public key. Gated by LARK_KEYCHAIN_IT +// because it mutates the dedicated lark-cli keychain store. The signer is now +// cgo-free (purego runtime FFI), so it runs with CGO_ENABLED=0. Run with: +// +// LARK_KEYCHAIN_IT=1 go test -run RoundTrip ./internal/keysigner/ +func TestKeychainSignerRoundTrip(t *testing.T) { + if os.Getenv("LARK_KEYCHAIN_IT") == "" { + t.Skip("set LARK_KEYCHAIN_IT=1 to run the macOS Keychain integration test") + } + t.Setenv("HOME", t.TempDir()) + s := keychainSigner{} + ref := KeyRef{Label: "lark-cli-keychain-it"} + + pub, err := s.EnsureKey(context.Background(), ref) + if err != nil { + t.Fatalf("EnsureKey: %v", err) + } + rsaPub, ok := pub.(*rsa.PublicKey) + if !ok { + t.Fatalf("public key = %T, want *rsa.PublicKey", pub) + } + if alg, err := AlgForKey(pub); err != nil || alg != AlgRS256 { + t.Fatalf("AlgForKey = %q, %v; want RS256", alg, err) + } + + input := []byte("header.payload") + sig, alg, err := s.Sign(context.Background(), ref, input) + if err != nil { + t.Fatalf("Sign: %v", err) + } + if alg != AlgRS256 { + t.Errorf("Sign alg = %q, want RS256", alg) + } + h := sha256.Sum256(input) + if err := rsa.VerifyPKCS1v15(rsaPub, crypto.SHA256, h[:], sig); err != nil { + t.Errorf("RS256 signature did not verify: %v", err) + } + + md, err := readKeyMetadata(ref.Label) + if err != nil { + t.Fatalf("read metadata: %v", err) + } + appLabel, err := hex.DecodeString(md.AppLabel) + if err != nil { + t.Fatalf("decode app label: %v", err) + } + keychain, err := ensureKeychain() + if err != nil { + t.Fatalf("ensure keychain for export check: %v", err) + } + privateKeyRef, err := findPrivateKey(appLabel, keychain) + if err != nil { + t.Fatalf("find private key for export check: %v", err) + } + defer cfRelease(privateKeyRef) + var exportErr uintptr + if exported := secKeyCopyExternal(privateKeyRef, &exportErr); exported != 0 { + cfRelease(exported) + t.Fatal("private key was exportable") + } + if exportErr != 0 { + cfRelease(exportErr) + } +} diff --git a/internal/keysigner/signer_sks.go b/internal/keysigner/signer_sks.go new file mode 100644 index 0000000000..9bbf73f0dc --- /dev/null +++ b/internal/keysigner/signer_sks.go @@ -0,0 +1,135 @@ +//go:build linux || (windows && amd64) + +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// TPM 2.0 signer (compiled into every linux and windows/amd64 build, no build +// tag required), backed by github.com/facebookincubator/sks. +// +// sks holds a non-exportable ECDSA P-256 key in the platform TPM and signs +// SHA-256 digests. On Linux it talks to /dev/tpmrm0; on Windows it uses the +// Microsoft Platform Crypto Provider (CNG). Both backends return an ASN.1 DER +// ECDSA signature, which we convert to the fixed-width r||s form JWS requires for +// ES256 (see ecdsaDERToJOSE). One key is created on the first private_key_jwt +// registration (DefaultKeyLabel) and reused for subsequent app registrations and +// every client_assertion on the same device. +// +// Excluded from windows/arm64: the sks Windows dependency stack (go-ole) has no +// arm64 VARIANT and fails to compile, so windows/arm64 falls back to +// client_secret only (keysigner.Active() is nil). On darwin the keychain signer +// is used instead. CGO is never required. +package keysigner + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/sha256" + "fmt" + "io" + + "github.com/facebookincubator/flog" + "github.com/facebookincubator/sks" +) + +// p256ByteLen is the P-256 coordinate width. sks regular keys are always ECDSA +// P-256, so ES256 signatures are 2*p256ByteLen bytes of r||s. +const p256ByteLen = 32 + +// keyTag is the sks key tag. Both the Linux and Windows sks backends address +// keys by label and ignore the tag, but the macOS backend uses it, so we set a +// stable namespaced value for forward compatibility. +const keyTag = "com.larksuite.cli" + +// sksSigner implements Signer (and HardwareProber) using a non-exportable +// TPM 2.0 ECDSA key via sks. +type sksSigner struct{} + +func init() { + Register(sksSigner{}) + // This sks version logs verbose TPM-operation chatter to stderr via flog (a + // glog fork it owns exclusively) — e.g. "Loaded TPM device", "Found handle + // for key" on every sign. The CLI does not use flog, so silence it + // process-wide here; real failures are returned as errors, never relied upon + // from these logs. (Newer sks switched to slog, but that lands only on its + // go-1.24 line, which we avoid to keep the module on go 1.23.) + flog.SetOutput(io.Discard) +} + +// EnsureKey returns the public key for ref, creating the TPM key if absent. +// sks.NewKey is find-or-create: it returns the existing key when one is present. +func (sksSigner) EnsureKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) { + key, err := sks.NewKey(ref.Label, keyTag, false, true, nil) + if err != nil { + return nil, fmt.Errorf("keysigner: ensure TPM key %q: %w", ref.Label, err) + } + defer key.Close() + return ecdsaPublic(ref.Label, key.Public()) +} + +// PublicKey returns the public key for ref without creating it. FromLabelTag does +// not touch the TPM until Public() loads the sealed key; a missing key yields a +// nil public key, which we surface as an error — at runtime the key MUST already +// exist (it was bound to the app at registration), so we never silently mint a +// new, unbound one here. +func (sksSigner) PublicKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) { + pub := sks.FromLabelTag(ref.Label).Public() + if pub == nil { + return nil, fmt.Errorf("keysigner: TPM key %q not found", ref.Label) + } + return ecdsaPublic(ref.Label, pub) +} + +// Sign signs signingInput with the TPM key and returns a JOSE-format ES256 +// signature (fixed-width r||s) plus its alg. +func (sksSigner) Sign(_ context.Context, ref KeyRef, signingInput []byte) ([]byte, string, error) { + key, err := sks.NewKey(ref.Label, keyTag, false, true, nil) + if err != nil { + return nil, "", fmt.Errorf("keysigner: load TPM key %q: %w", ref.Label, err) + } + defer key.Close() + + // ES256 signs the SHA-256 digest of the JWS signing input. + digest := sha256.Sum256(signingInput) + der, err := key.Sign(nil, digest[:], crypto.SHA256) + if err != nil { + return nil, "", fmt.Errorf("keysigner: TPM sign with key %q: %w", ref.Label, err) + } + // Both sks backends emit ASN.1 DER; JWS ES256 requires fixed-width r||s + // (RFC 7518 §3.4). + rs, err := ecdsaDERToJOSE(der, p256ByteLen) + if err != nil { + return nil, "", err + } + return rs, AlgES256, nil +} + +// ProbeHardware reports on the TPM backing this signer without touching any key. +// A failure to reach the TPM (no device, permission denied, not TPM 2.0) is +// reported as Available=false with Reason set, NOT as a Go error — the probe +// still succeeded in determining that the TEE is currently unusable. +func (sksSigner) ProbeHardware(_ context.Context) (HardwareInfo, error) { + info := HardwareInfo{Backend: "tpm2"} + data, err := sks.GetSecureHardwareVendorData() + if err != nil { + info.Reason = cleanProbeError(err) + return info, nil + } + info.VendorName = data.VendorName + info.VendorInfo = data.VendorInfo + info.Available = data.IsTPM20CompliantDevice + if !info.Available { + info.Reason = "secure hardware is not a TPM 2.0 compliant device" + } + return info, nil +} + +// ecdsaPublic asserts that an sks public key is an ECDSA key (it always is for +// regular sks keys) so the caller gets the concrete type AlgForKey/PublicKeyJWK expect. +func ecdsaPublic(label string, pub crypto.PublicKey) (*ecdsa.PublicKey, error) { + ecPub, ok := pub.(*ecdsa.PublicKey) + if !ok { + return nil, fmt.Errorf("keysigner: TPM key %q public is %T, want *ecdsa.PublicKey", label, pub) + } + return ecPub, nil +} diff --git a/internal/keysigner/signer_sks_test.go b/internal/keysigner/signer_sks_test.go new file mode 100644 index 0000000000..c2328050d2 --- /dev/null +++ b/internal/keysigner/signer_sks_test.go @@ -0,0 +1,122 @@ +//go:build linux || (windows && amd64) + +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package keysigner + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/sha256" + "io" + "math/big" + "strings" + "testing" + + "github.com/facebookincubator/flog" + "github.com/facebookincubator/sks" +) + +// TestFlogSilenced verifies the mechanism init() relies on to keep sks's flog +// TPM chatter off the CLI's stderr: SetOutput redirects flog, and io.Discard +// drops it. Cleanup restores io.Discard so init()'s silencing holds for the +// rest of the package's tests. +func TestFlogSilenced(t *testing.T) { + var buf bytes.Buffer + flog.SetOutput(&buf) + t.Cleanup(func() { flog.SetOutput(io.Discard) }) + + flog.Info("captured-line") + if !strings.Contains(buf.String(), "captured-line") { + t.Fatalf("flog.SetOutput(buffer) did not capture output: %q", buf.String()) + } + + flog.SetOutput(io.Discard) + buf.Reset() + flog.Info("should-be-discarded") + if buf.Len() != 0 { + t.Errorf("flog output not discarded: %q", buf.String()) + } +} + +// requireTEE skips the test unless the TPM is present and usable. On a Linux +// machine with a TPM but a restrictive device owner (`/dev/tpmrm0` is `tss:tss` +// by default), grant access with `sudo usermod -aG tss $USER` then re-login, or +// run the test under sudo. +func requireTEE(t *testing.T) { + t.Helper() + info, err := sksSigner{}.ProbeHardware(context.Background()) + if err != nil || !info.Available { + reason := info.Reason + if err != nil { + reason = err.Error() + } + t.Skipf("TEE not available (%s)", reason) + } +} + +// TestSKSSignerRoundTrip exercises the full registration→assertion contract +// against the real TPM: create the key, read it back without creating, derive +// the JWS alg + JWK, sign, and verify the fixed-width r||s output. +func TestSKSSignerRoundTrip(t *testing.T) { + requireTEE(t) + + var s sksSigner + ctx := context.Background() + ref := KeyRef{Label: "larksuite-cli-test"} + + // Best-effort cleanup so the test key does not linger in the TPM-sealed store. + t.Cleanup(func() { + if k, err := sks.NewKey(ref.Label, keyTag, false, true, nil); err == nil { + _ = k.Remove() + _ = k.Close() + } + }) + + pub, err := s.EnsureKey(ctx, ref) + if err != nil { + t.Fatalf("EnsureKey: %v", err) + } + ecPub, ok := pub.(*ecdsa.PublicKey) + if !ok { + t.Fatalf("EnsureKey returned %T, want *ecdsa.PublicKey", pub) + } + + // PublicKey (no-create) must return the same key bound at EnsureKey. + pub2, err := s.PublicKey(ctx, ref) + if err != nil { + t.Fatalf("PublicKey: %v", err) + } + if !ecPub.Equal(pub2) { + t.Fatal("PublicKey returned a different key than EnsureKey") + } + + // The JWT layer derives alg + JWK from the public key; both must work. + if alg, err := AlgForKey(pub); err != nil || alg != AlgES256 { + t.Fatalf("AlgForKey = %q, %v; want ES256", alg, err) + } + if _, err := PublicKeyJWK(pub); err != nil { + t.Fatalf("PublicKeyJWK: %v", err) + } + + // Sign a representative JWS signing input and verify the converted r||s. + input := []byte("eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJjbGkifQ") + sig, alg, err := s.Sign(ctx, ref, input) + if err != nil { + t.Fatalf("Sign: %v", err) + } + if alg != AlgES256 { + t.Fatalf("Sign alg = %q, want ES256", alg) + } + if len(sig) != 2*p256ByteLen { + t.Fatalf("len(sig) = %d, want %d (fixed-width r||s)", len(sig), 2*p256ByteLen) + } + digest := sha256.Sum256(input) + r := new(big.Int).SetBytes(sig[:p256ByteLen]) + ss := new(big.Int).SetBytes(sig[p256ByteLen:]) + if !ecdsa.Verify(ecPub, digest[:], r, ss) { + t.Fatal("TPM signature did not verify against the public key") + } +} diff --git a/release_config_test.go b/release_config_test.go new file mode 100644 index 0000000000..3575abf6f1 --- /dev/null +++ b/release_config_test.go @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package main + +import ( + "testing" + + "github.com/larksuite/cli/internal/vfs" + "gopkg.in/yaml.v3" +) + +func TestGoReleaserPlatformMatrix(t *testing.T) { + data, err := vfs.ReadFile(".goreleaser.yml") + if err != nil { + t.Fatalf("read .goreleaser.yml: %v", err) + } + + var config struct { + Builds []struct { + ID string `yaml:"id"` + GOArch []string `yaml:"goarch"` + } `yaml:"builds"` + } + if err := yaml.Unmarshal(data, &config); err != nil { + t.Fatalf("parse .goreleaser.yml: %v", err) + } + + builds := make(map[string][]string, len(config.Builds)) + for _, build := range config.Builds { + builds[build.ID] = build.GOArch + } + + if !contains(builds["linux"], "riscv64") { + t.Errorf("linux release matrix must include riscv64; got %v", builds["linux"]) + } + if contains(builds["darwin"], "riscv64") { + t.Errorf("darwin release matrix must not include unsupported riscv64; got %v", builds["darwin"]) + } +} + +func contains(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/scripts/build-pkg-pr-new.sh b/scripts/build-pkg-pr-new.sh index edd6117693..2d0eb21794 100755 --- a/scripts/build-pkg-pr-new.sh +++ b/scripts/build-pkg-pr-new.sh @@ -24,6 +24,10 @@ build_target() { ext=".exe" fi + # The platform key signers are compiled in by build constraint, no tags: + # darwin keychain (//go:build darwin) and linux/windows-amd64 TPM + # (//go:build linux || (windows && amd64)). windows/arm64 arch-excludes the TPM + # signer (go-ole has no arm64) and falls back to client_secret only. local output="$OUT_DIR/bin/lark-cli-${goos}-${goarch}${ext}" echo "Building ${goos}/${goarch} -> ${output}" CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags "$LDFLAGS" -o "$output" ./main.go diff --git a/sidecar/server-multi-tenant-demo/auth_bridge.go b/sidecar/server-multi-tenant-demo/auth_bridge.go index 2670ff520e..3a4ee575e7 100644 --- a/sidecar/server-multi-tenant-demo/auth_bridge.go +++ b/sidecar/server-multi-tenant-demo/auth_bridge.go @@ -204,7 +204,7 @@ func (ab *authBridge) handleLogin(w http.ResponseWriter, _ *http.Request, body [ len(strings.Fields(scope)), req.Domains, clientID) authResp, err := larkauth.RequestDeviceAuthorization( - ab.httpCl, ab.appID, ab.appSecret, ab.brand, scope, io.Discard, + context.Background(), ab.httpCl, larkauth.ClientAuth{AppID: ab.appID, AppSecret: ab.appSecret}, ab.brand, scope, io.Discard, ) if err != nil { jsonError(w, http.StatusBadGateway, "device authorization failed: "+err.Error()) @@ -255,7 +255,7 @@ func (ab *authBridge) handlePoll(w http.ResponseWriter, r *http.Request, body [] }() result := larkauth.PollDeviceToken( - ctx, ab.httpCl, ab.appID, ab.appSecret, ab.brand, + ctx, ab.httpCl, larkauth.ClientAuth{AppID: ab.appID, AppSecret: ab.appSecret}, ab.brand, req.DeviceCode, 5, 600, io.Discard, )