Compare commits

...

1 Commits

Author SHA1 Message Date
evandance
a81e5d17c5 feat(config): bind OpenClaw keyless credentials 2026-07-22 19:46:33 +08:00
66 changed files with 9154 additions and 228 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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
}
// ──────────────────────────────────────────────────────────────

View File

@@ -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)

View File

@@ -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",

View File

@@ -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()))

View File

@@ -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)
}
}

View File

@@ -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]
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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 {

16
go.mod
View File

@@ -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
)

37
go.sum
View File

@@ -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=

View File

@@ -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 != "" &&

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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")
}

152
internal/auth/jwt/jwt.go Normal file
View File

@@ -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))
}

View File

@@ -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"])
}
}

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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,
}}
}

View File

@@ -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

View File

@@ -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{

View File

@@ -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
// ---------------------------------------------------------------------------

View File

@@ -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://")
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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,
}
}

View File

@@ -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 {

View File

@@ -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)",

View File

@@ -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()
}

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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))
}

View File

@@ -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,
})
}

View File

@@ -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:
// <project>/node_modules/@larksuite/openclaw-lark
// <project>/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
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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")
}
}

49
release_config_test.go Normal file
View File

@@ -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
}

View File

@@ -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

View File

@@ -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,
)