diff --git a/Makefile b/Makefile index 73913b28..b46ab485 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)" GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go' -.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks +.PHONY: all help build check-safechat test-safechat rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks all: setup-hooks fmt lint build test rebuild @@ -18,6 +18,8 @@ help: @printf "Available targets:\n" @printf " make build - Build the dws CLI binary\n" @printf " make test - Run the Go test suite\n" + @printf " make check-safechat - Compile and vet the SafeChat message-crypto backend (needs CGO)\n" + @printf " make test-safechat - Run the message-crypto tests against the SafeChat backend\n" @printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\n" @printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n" @printf " make lint - Run formatting checks, go vet, and staticcheck\n" @@ -52,6 +54,16 @@ build: rebuild: @./scripts/dev/build.sh +# No dws command imports internal/msgcrypto yet, so a tagged CLI build would +# link nothing extra and look identical to the default binary. Gate the package +# itself until a caller wires it in. +check-safechat: + @CGO_ENABLED=1 $(GO) build -tags safechat ./internal/msgcrypto/... + @CGO_ENABLED=1 $(GO) vet -tags safechat ./internal/msgcrypto/... + +test-safechat: + @CGO_ENABLED=1 $(GO) test -count=1 -tags safechat ./internal/msgcrypto/... + test: @DWS_PACKAGE_VERSION="$(DWS_PACKAGE_VERSION)" $(GO) test -count=1 -timeout=10m ./... diff --git a/go.mod b/go.mod index 62bec438..fa2cb12c 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.25.9 replace gitlab.alibaba-inc.com/aes/aem-go-sdk => ./third_party/aem-go-sdk +replace safechat-go-sdk => ./third_party/safechat-go-sdk + require ( github.com/Microsoft/go-winio v0.6.2 github.com/RealAlexandreAI/json-repair v0.0.15 @@ -24,6 +26,7 @@ require ( golang.org/x/crypto v0.49.0 golang.org/x/sys v0.42.0 golang.org/x/text v0.35.0 + safechat-go-sdk v0.0.0 ) require ( diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go index 31a204cc..e7175232 100644 --- a/internal/auth/endpoints.go +++ b/internal/auth/endpoints.go @@ -113,9 +113,10 @@ const ( ClientIDPath = "/cli/clientId" // MCP OAuth endpoints (used when clientId is fetched from MCP). - MCPOAuthTokenPath = "/oauth2/getToken" - MCPRefreshTokenPath = "/oauth2/refreshToken" - MCPRevokeTokenPath = "/oauth2/revokeToken" + MCPOAuthTokenPath = "/oauth2/getToken" + MCPRefreshTokenPath = "/oauth2/refreshToken" + MCPRevokeTokenPath = "/oauth2/revokeToken" + MCPVendorAuthCodePath = "/oauth2/vendorAuthCode" // App-level access token endpoints (for dws api raw calls). diff --git a/internal/auth/vendor_authcode.go b/internal/auth/vendor_authcode.go new file mode 100644 index 00000000..79ed94aa --- /dev/null +++ b/internal/auth/vendor_authcode.go @@ -0,0 +1,192 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" +) + +const ( + headerUserAccessToken = "x-user-access-token" + headerDWSClientID = "x-dws-client-id" + headerDWSCLIVersion = "x-dws-cli-version" + + // VendorAuthCode default / documented portal expiresIn, used only when + // the success body omits a positive value. Callers must still prefer + // the response field. + DefaultVendorAuthCodeExpiresIn = 120 + + VendorAuthCodeParamError = "PARAM_ERROR" + VendorAuthCodeVendorUnsupported = "VENDOR_UNSUPPORTED" + VendorAuthCodeTokenInvalid = "TOKEN_INVALID" + VendorAuthCodeOrgMismatch = "ORG_MISMATCH" + VendorAuthCodeUserNotInOrg = "USER_NOT_IN_ORG" + VendorAuthCodeVendorNotEnabled = "VENDOR_NOT_ENABLED" + VendorAuthCodeRateLimited = "RATE_LIMITED" + VendorAuthCodeInternalError = "INTERNAL_ERROR" +) + +// VendorAuthCodeInput is a POST /oauth2/vendorAuthCode call. The body is +// only vendor + corpId; redirectURI and domain must not be sent. +type VendorAuthCodeInput struct { + AccessToken string + ClientID string + CLIVersion string + LoginRegion LoginRegion + Vendor string + CorpID string + HTTPClient *http.Client + // BaseURL overrides MCPBaseURLForLoginRegion. Tests use it; production + // callers leave it empty. + BaseURL string +} + +// VendorAuthCodeResult is the success VO from portal. +type VendorAuthCodeResult struct { + AuthCode string + ExpiresIn int +} + +// VendorAuthCodeError is a portal business error carried in an HTTP 200 +// ServiceResult body (same envelope as /oauth2/getToken). +type VendorAuthCodeError struct { + Code string + Message string +} + +func (e *VendorAuthCodeError) Error() string { + if e == nil { + return "vendorAuthCode failed" + } + if strings.TrimSpace(e.Message) != "" { + return fmt.Sprintf("vendorAuthCode %s: %s", e.Code, e.Message) + } + return fmt.Sprintf("vendorAuthCode %s", e.Code) +} + +// Retryable reports whether DWS should retry this portal error once. +func (e *VendorAuthCodeError) Retryable() bool { + if e == nil { + return false + } + switch e.Code { + case VendorAuthCodeTokenInvalid, VendorAuthCodeRateLimited, VendorAuthCodeInternalError: + return true + default: + return false + } +} + +// FetchVendorAuthCode POSTs {vendor, corpId} to /oauth2/vendorAuthCode. +// HTTP is expected to be 200; errors are read from body.errorCode. +func FetchVendorAuthCode(ctx context.Context, in VendorAuthCodeInput) (*VendorAuthCodeResult, error) { + vendor := strings.ToLower(strings.TrimSpace(in.Vendor)) + corpID := strings.TrimSpace(in.CorpID) + token := strings.TrimSpace(in.AccessToken) + clientID := strings.TrimSpace(in.ClientID) + if token == "" || clientID == "" || vendor == "" || corpID == "" { + return nil, &VendorAuthCodeError{ + Code: VendorAuthCodeParamError, + Message: "token, clientId, vendor and corpId are required", + } + } + + base := strings.TrimRight(strings.TrimSpace(in.BaseURL), "/") + if base == "" { + base = strings.TrimRight(MCPBaseURLForLoginRegion(in.LoginRegion), "/") + } + endpoint := base + MCPVendorAuthCodePath + + payload, err := json.Marshal(struct { + Vendor string `json:"vendor"` + CorpID string `json:"corpId"` + }{Vendor: vendor, CorpID: corpID}) + if err != nil { + return nil, fmt.Errorf("marshaling vendorAuthCode request: %w", err) + } + + req, err := oauthNewRequest(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("creating vendorAuthCode request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set(headerUserAccessToken, token) + req.Header.Set(headerDWSClientID, clientID) + if ver := strings.TrimSpace(in.CLIVersion); ver != "" { + req.Header.Set(headerDWSCLIVersion, ver) + } + applyEditionEnterpriseCredentialHeaders(req) + + client := in.HTTPClient + if client == nil { + client = oauthHTTPClient + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("sending vendorAuthCode request: %w", err) + } + defer resp.Body.Close() + + data, readErr := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) + if resp.StatusCode != http.StatusOK { + if readErr != nil { + data = nil + } + return nil, &HTTPStatusError{ + StatusCode: resp.StatusCode, + responseBody: truncateBody(data, 200), + } + } + if readErr != nil { + return nil, fmt.Errorf("reading vendorAuthCode response: %w", readErr) + } + return parseVendorAuthCodeResponse(data) +} + +func parseVendorAuthCodeResponse(body []byte) (*VendorAuthCodeResult, error) { + var resp struct { + AuthCode string `json:"authCode"` + ExpiresIn int `json:"expiresIn"` + Success *bool `json:"success"` + ErrorCode string `json:"errorCode"` + ErrorMsg string `json:"errorMsg"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("parsing vendorAuthCode response: %w", err) + } + if resp.ErrorCode != "" || resp.ErrorMsg != "" || (resp.Success != nil && !*resp.Success) { + code := strings.TrimSpace(resp.ErrorCode) + if code == "" { + code = VendorAuthCodeInternalError + } + return nil, &VendorAuthCodeError{Code: code, Message: resp.ErrorMsg} + } + authCode := strings.TrimSpace(resp.AuthCode) + if authCode == "" { + return nil, fmt.Errorf("vendorAuthCode response missing authCode") + } + expiresIn := resp.ExpiresIn + if expiresIn <= 0 { + expiresIn = DefaultVendorAuthCodeExpiresIn + } + return &VendorAuthCodeResult{AuthCode: authCode, ExpiresIn: expiresIn}, nil +} diff --git a/internal/auth/vendor_authcode_test.go b/internal/auth/vendor_authcode_test.go new file mode 100644 index 00000000..e7bb15e6 --- /dev/null +++ b/internal/auth/vendor_authcode_test.go @@ -0,0 +1,178 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestFetchVendorAuthCodeSuccessEnvelope(t *testing.T) { + var gotBody map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != MCPVendorAuthCodePath { + t.Fatalf("path = %q, want %s", r.URL.Path, MCPVendorAuthCodePath) + } + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + if got := r.Header.Get("x-user-access-token"); got != "user-token" { + t.Fatalf("x-user-access-token = %q", got) + } + if got := r.Header.Get("x-dws-client-id"); got != "dws-client" { + t.Fatalf("x-dws-client-id = %q", got) + } + if got := r.Header.Get("x-dws-cli-version"); got != "1.2.3" { + t.Fatalf("x-dws-cli-version = %q", got) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + w.Header().Set("Cache-Control", "no-store") + _, _ = io.WriteString(w, `{"authCode":"tmp-code","expiresIn":120}`) + })) + defer srv.Close() + + got, err := FetchVendorAuthCode(context.Background(), VendorAuthCodeInput{ + AccessToken: "user-token", + ClientID: "dws-client", + CLIVersion: "1.2.3", + Vendor: "SafeChat", + CorpID: "dingxxxxxxxxxxxx", + HTTPClient: srv.Client(), + BaseURL: srv.URL, + }) + if err != nil { + t.Fatalf("FetchVendorAuthCode() = %v", err) + } + if got.AuthCode != "tmp-code" || got.ExpiresIn != 120 { + t.Fatalf("result = %+v", got) + } + if gotBody["vendor"] != "safechat" || gotBody["corpId"] != "dingxxxxxxxxxxxx" { + t.Fatalf("posted body = %v", gotBody) + } + if _, ok := gotBody["redirectURI"]; ok { + t.Fatalf("posted redirectURI, body = %v", gotBody) + } + if _, ok := gotBody["domain"]; ok { + t.Fatalf("posted domain, body = %v", gotBody) + } +} + +func TestFetchVendorAuthCodeParsesAlways200ServiceResult(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"success":false,"errorCode":"VENDOR_NOT_ENABLED","errorMsg":"not installed"}`) + })) + defer srv.Close() + + _, err := FetchVendorAuthCode(context.Background(), VendorAuthCodeInput{ + AccessToken: "user-token", + ClientID: "dws-client", + Vendor: "safechat", + CorpID: "dingxxxxxxxxxxxx", + HTTPClient: srv.Client(), + BaseURL: srv.URL, + }) + var verr *VendorAuthCodeError + if !errors.As(err, &verr) || verr.Code != VendorAuthCodeVendorNotEnabled { + t.Fatalf("error = %v, want VENDOR_NOT_ENABLED", err) + } + if verr.Retryable() { + t.Fatal("VENDOR_NOT_ENABLED must not be retryable") + } +} + +func TestFetchVendorAuthCodeRequiresLocalFields(t *testing.T) { + _, err := FetchVendorAuthCode(context.Background(), VendorAuthCodeInput{Vendor: "safechat", CorpID: "ding"}) + var verr *VendorAuthCodeError + if !errors.As(err, &verr) || verr.Code != VendorAuthCodeParamError { + t.Fatalf("error = %v, want PARAM_ERROR", err) + } +} + +func TestFetchVendorAuthCodeKeepsHTTPStatusOnNon200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "oops", http.StatusBadGateway) + })) + defer srv.Close() + + _, err := FetchVendorAuthCode(context.Background(), VendorAuthCodeInput{ + AccessToken: "user-token", + ClientID: "dws-client", + Vendor: "safechat", + CorpID: "dingxxxxxxxxxxxx", + HTTPClient: srv.Client(), + BaseURL: srv.URL, + }) + var statusErr *HTTPStatusError + if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusBadGateway { + t.Fatalf("error = %v, want HTTP 502", err) + } +} + +func TestParseVendorAuthCodeResponseDefaultsExpiresIn(t *testing.T) { + got, err := parseVendorAuthCodeResponse([]byte(`{"authCode":"x"}`)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got.ExpiresIn != DefaultVendorAuthCodeExpiresIn { + t.Fatalf("expiresIn = %d, want %d", got.ExpiresIn, DefaultVendorAuthCodeExpiresIn) + } +} + +func TestVendorAuthCodeErrorRetryable(t *testing.T) { + for _, code := range []string{VendorAuthCodeTokenInvalid, VendorAuthCodeRateLimited, VendorAuthCodeInternalError} { + if !(&VendorAuthCodeError{Code: code}).Retryable() { + t.Fatalf("%s should be retryable", code) + } + } + if (&VendorAuthCodeError{Code: VendorAuthCodeOrgMismatch}).Retryable() { + t.Fatal("ORG_MISMATCH must not be retryable") + } +} + +func TestFetchVendorAuthCodeDoesNotSendBlankCLIVersionHeader(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("x-dws-cli-version"); got != "" { + t.Fatalf("x-dws-cli-version = %q, want empty", got) + } + _, _ = io.WriteString(w, `{"authCode":"tmp-code","expiresIn":90}`) + })) + defer srv.Close() + + got, err := FetchVendorAuthCode(context.Background(), VendorAuthCodeInput{ + AccessToken: "user-token", + ClientID: "dws-client", + Vendor: "safechat", + CorpID: "dingxxxxxxxxxxxx", + HTTPClient: srv.Client(), + BaseURL: srv.URL, + }) + if err != nil { + t.Fatalf("FetchVendorAuthCode() = %v", err) + } + if got.ExpiresIn != 90 { + t.Fatalf("expiresIn = %d, want 90 from response", got.ExpiresIn) + } + if strings.Contains(got.AuthCode, "redirect") { + t.Fatalf("unexpected code %q", got.AuthCode) + } +} diff --git a/internal/keychain/coverage_edges_test.go b/internal/keychain/coverage_edges_test.go index c0001374..efcff36e 100644 --- a/internal/keychain/coverage_edges_test.go +++ b/internal/keychain/coverage_edges_test.go @@ -3,6 +3,7 @@ package keychain import ( + "bytes" "encoding/base64" "errors" "os" @@ -246,6 +247,45 @@ func TestCrossPlatformCoverageDarwinDEKKeyringEdges(t *testing.T) { if got, err := getOrCreateDEK("generate-missing"); err != nil || len(got) != dekBytes { t.Fatalf("generate missing = %d, %v", len(got), err) } + + existing := bytesOf(7, dekBytes) + setCalls := 0 + keyringGet = func(string, string) (string, error) { + return base64.StdEncoding.EncodeToString(existing), nil + } + keyringSet = func(string, string, string) error { + setCalls++ + return errors.New("duplicate write must not replace an existing DEK") + } + got, err := getOrCreateDEK("reuse-existing") + if err != nil || !bytes.Equal(got, existing) { + t.Fatalf("reuse existing = %d, %v", len(got), err) + } + if setCalls != 0 { + t.Fatalf("reuse existing set calls = %d, want 0", setCalls) + } + + gets := 0 + setCalls = 0 + keyringGet = func(string, string) (string, error) { + gets++ + if gets == 1 { + return "", keyring.ErrNotFound + } + return encoded, nil + } + keyringSet = func(string, string, string) error { + setCalls++ + return errors.New("already exists") + } + got, err = getOrCreateDEK("create-race") + if err != nil || !bytes.Equal(got, valid) { + t.Fatalf("create race = %d, %v", len(got), err) + } + if setCalls != 1 { + t.Fatalf("create race set calls = %d, want 1", setCalls) + } + keyringGet = func(string, string) (string, error) { return "", keyring.ErrNotFound } keychainRandRead = func([]byte) (int, error) { return 0, errKeychainInjected } if _, err := getOrCreateDEK("rand"); err == nil { t.Fatal("rand error expected") diff --git a/internal/keychain/keychain_darwin.go b/internal/keychain/keychain_darwin.go index 529040bb..74154895 100644 --- a/internal/keychain/keychain_darwin.go +++ b/internal/keychain/keychain_darwin.go @@ -237,6 +237,26 @@ func getSystemDEKReadOnly(service string) ([]byte, error) { return key, err } +func decodeSystemDEK(encodedKey string) ([]byte, bool) { + key, err := base64.StdEncoding.DecodeString(encodedKey) + return key, err == nil && len(key) == dekBytes +} + +func readStoredSystemDEK(service string, runtime darwinKeychainRuntime) ([]byte, error) { + encodedKey, err := runtime.get(service, "dek") + if err == nil { + key, ok := decodeSystemDEK(encodedKey) + if ok { + return key, nil + } + return nil, fmt.Errorf("read DEK from macOS Keychain: %w", ErrDEKMissing) + } + if errors.Is(err, keyring.ErrNotFound) { + return nil, fmt.Errorf("read DEK from macOS Keychain: %w", ErrDEKMissing) + } + return nil, NewUnavailableError("read DEK from macOS Keychain", err) +} + func getSystemDEKReadOnlyWithRuntime(service string, runtime darwinKeychainRuntime) ([]byte, error, <-chan struct{}) { if err := runtime.checkAvailable(); err != nil { return nil, err, finishedDarwinKeychainWorker() @@ -244,18 +264,7 @@ func getSystemDEKReadOnlyWithRuntime(service string, runtime darwinKeychainRunti const operation = "read DEK from macOS Keychain" worker := startDarwinKeychainWorker(operation, func() ([]byte, error) { - encodedKey, err := runtime.get(service, "dek") - if err == nil { - key, decodeErr := base64.StdEncoding.DecodeString(encodedKey) - if decodeErr == nil && len(key) == dekBytes { - return key, nil - } - return nil, fmt.Errorf("read DEK from macOS Keychain: %w", ErrDEKMissing) - } - if errors.Is(err, keyring.ErrNotFound) { - return nil, fmt.Errorf("read DEK from macOS Keychain: %w", ErrDEKMissing) - } - return nil, NewUnavailableError("read DEK from macOS Keychain", err) + return readStoredSystemDEK(service, runtime) }) return waitDarwinKeychainWorker(runtime.timeout, operation, worker) @@ -276,28 +285,30 @@ func getOrCreateDEKWithRuntime(service string, runtime darwinKeychainRuntime) ([ const operation = "read or create DEK in macOS Keychain" worker := startDarwinKeychainWorker(operation, func() ([]byte, error) { - // Try to get existing DEK from system Keychain - encodedKey, err := runtime.get(service, "dek") + key, err := readStoredSystemDEK(service, runtime) if err == nil { - key, decodeErr := base64.StdEncoding.DecodeString(encodedKey) - if decodeErr == nil && len(key) == dekBytes { - return key, nil - } - } else if !errors.Is(err, keyring.ErrNotFound) { - return nil, NewUnavailableError("read DEK from macOS Keychain", err) + return key, nil + } + if !IsDEKMissing(err) { + return nil, err } - // Generate new DEK if not found or invalid - key := make([]byte, dekBytes) + // Generate a candidate only when the slot is empty or unreadable. + // Concurrent writers must not replace a DEK another process just stored. + key = make([]byte, dekBytes) if _, randErr := runtime.randRead(key); randErr != nil { return nil, randErr } - - // Store in system Keychain - encodedKey = base64.StdEncoding.EncodeToString(key) - if setErr := runtime.set(service, "dek", encodedKey); setErr != nil { + if setErr := runtime.set(service, "dek", base64.StdEncoding.EncodeToString(key)); setErr != nil { + existing, getErr := readStoredSystemDEK(service, runtime) + if getErr == nil { + return existing, nil + } return nil, NewUnavailableError("store DEK in macOS Keychain", setErr) } + if existing, getErr := readStoredSystemDEK(service, runtime); getErr == nil { + return existing, nil + } return key, nil }) diff --git a/internal/msgcrypto/authcode.go b/internal/msgcrypto/authcode.go new file mode 100644 index 00000000..56a75ac7 --- /dev/null +++ b/internal/msgcrypto/authcode.go @@ -0,0 +1,127 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package msgcrypto + +import ( + "context" + "errors" + "fmt" + "sync" + "time" +) + +// DefaultAuthCodeTTL is the unconsumed-cache window for a freshly minted +// vendor authCode. The portal issues codes with expiresIn=120s and they are +// one-shot, so the default stays under that server window. Prefer not wrapping +// PortalAuthCode in CachedAuthCode: mint in goProxy and discard after the key +// request. +const DefaultAuthCodeTTL = 90 * time.Second + +// ErrNoAuthCode means the provider returned an empty code without an error. +var ErrNoAuthCode = errors.New("msgcrypto: auth code provider returned an empty code") + +// AuthCodeProvider yields a DingTalk 免登 authCode for key-server +// authentication. DWS does not mint the code itself, so integrations inject an +// implementation. The backend calls this only from the vendor goProxy +// callback, never on every encrypt or decrypt. +// +// Implementations must be safe for concurrent use; the backend may call this +// from a CGO callback while an encrypt or decrypt call is in flight. +type AuthCodeProvider interface { + AuthCode(ctx context.Context) (string, error) +} + +// CorpAuthCodeProvider mints a code for a specific organization. PortalAuthCode +// implements this so goProxy can pass the C library's corpID. Domain and +// redirectURI are never part of this call. +type CorpAuthCodeProvider interface { + AuthCodeProvider + AuthCodeForCorp(ctx context.Context, corpID string) (string, error) +} + +// AuthCodeFunc adapts a function to AuthCodeProvider. +type AuthCodeFunc func(ctx context.Context) (string, error) + +// AuthCode calls f. +func (f AuthCodeFunc) AuthCode(ctx context.Context) (string, error) { return f(ctx) } + +// StaticAuthCode returns a provider that always yields code. It is meant for +// tests and manual integration runs; a static code stops working once the +// server-side five-minute window closes. +func StaticAuthCode(code string) AuthCodeProvider { + return AuthCodeFunc(func(context.Context) (string, error) { + if code == "" { + return "", ErrNoAuthCode + } + return code, nil + }) +} + +// CachedAuthCode memoises an AuthCodeProvider for a TTL so a burst of key +// requests does not trigger one upstream call each. +type CachedAuthCode struct { + provider AuthCodeProvider + ttl time.Duration + now func() time.Time + + mu sync.Mutex + code string + expiresAt time.Time +} + +// NewCachedAuthCode wraps provider with a TTL cache. A ttl of zero or less +// selects DefaultAuthCodeTTL. +func NewCachedAuthCode(provider AuthCodeProvider, ttl time.Duration) *CachedAuthCode { + if ttl <= 0 { + ttl = DefaultAuthCodeTTL + } + return &CachedAuthCode{provider: provider, ttl: ttl, now: time.Now} +} + +// AuthCode returns the cached code when it is still fresh, otherwise fetches a +// new one. A failed fetch leaves no stale value behind. +func (c *CachedAuthCode) AuthCode(ctx context.Context) (string, error) { + if c.provider == nil { + return "", ErrNoAuthCodeProvider + } + + c.mu.Lock() + defer c.mu.Unlock() + + if c.code != "" && c.now().Before(c.expiresAt) { + return c.code, nil + } + + code, err := c.provider.AuthCode(ctx) + if err != nil { + c.code, c.expiresAt = "", time.Time{} + return "", fmt.Errorf("msgcrypto: fetch auth code: %w", err) + } + if code == "" { + c.code, c.expiresAt = "", time.Time{} + return "", ErrNoAuthCode + } + + c.code = code + c.expiresAt = c.now().Add(c.ttl) + return code, nil +} + +// Invalidate drops the cached code so the next AuthCode call refetches. The +// backend calls this after the key server rejects a code. +func (c *CachedAuthCode) Invalidate() { + c.mu.Lock() + c.code, c.expiresAt = "", time.Time{} + c.mu.Unlock() +} diff --git a/internal/msgcrypto/authcode_test.go b/internal/msgcrypto/authcode_test.go new file mode 100644 index 00000000..4b84836a --- /dev/null +++ b/internal/msgcrypto/authcode_test.go @@ -0,0 +1,228 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package msgcrypto + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +// countingProvider hands out a fresh code per call and records how often it was +// asked, so cache behaviour can be asserted. +type countingProvider struct { + mu sync.Mutex + calls int + code string + err error +} + +func (p *countingProvider) AuthCode(context.Context) (string, error) { + p.mu.Lock() + defer p.mu.Unlock() + p.calls++ + if p.err != nil { + return "", p.err + } + if p.code != "" { + return p.code, nil + } + return "code-" + string(rune('a'+p.calls-1)), nil +} + +// callCount reports the number of upstream fetches. +func (p *countingProvider) callCount() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.calls +} + +func TestAuthCodeFuncAdaptsFunction(t *testing.T) { + provider := AuthCodeFunc(func(context.Context) (string, error) { return "abc", nil }) + code, err := provider.AuthCode(context.Background()) + if err != nil || code != "abc" { + t.Fatalf("AuthCode() = %q, %v; want abc, nil", code, err) + } +} + +func TestStaticAuthCodeReturnsCode(t *testing.T) { + code, err := StaticAuthCode("fixed").AuthCode(context.Background()) + if err != nil || code != "fixed" { + t.Fatalf("AuthCode() = %q, %v; want fixed, nil", code, err) + } +} + +func TestStaticAuthCodeRejectsEmptyCode(t *testing.T) { + _, err := StaticAuthCode("").AuthCode(context.Background()) + if !errors.Is(err, ErrNoAuthCode) { + t.Fatalf("AuthCode() = %v, want ErrNoAuthCode", err) + } +} + +func TestCachedAuthCodeReusesCodeWithinTTL(t *testing.T) { + provider := &countingProvider{code: "same"} + cache := NewCachedAuthCode(provider, time.Minute) + + for i := 0; i < 5; i++ { + code, err := cache.AuthCode(context.Background()) + if err != nil { + t.Fatalf("AuthCode() #%d = %v", i+1, err) + } + if code != "same" { + t.Fatalf("AuthCode() #%d = %q, want same", i+1, code) + } + } + if got := provider.callCount(); got != 1 { + t.Fatalf("upstream called %d times, want 1 (the code must be cached)", got) + } +} + +func TestCachedAuthCodeRefetchesAfterTTL(t *testing.T) { + provider := &countingProvider{} + cache := NewCachedAuthCode(provider, time.Minute) + + now := time.Now() + cache.now = func() time.Time { return now } + + first, err := cache.AuthCode(context.Background()) + if err != nil { + t.Fatalf("first AuthCode() = %v", err) + } + + // Move past the TTL. The DingTalk code expires server-side, so a stale + // one must not be reused. + now = now.Add(time.Minute + time.Second) + + second, err := cache.AuthCode(context.Background()) + if err != nil { + t.Fatalf("second AuthCode() = %v", err) + } + if first == second { + t.Fatalf("AuthCode() returned the same code %q after the TTL expired", first) + } + if got := provider.callCount(); got != 2 { + t.Fatalf("upstream called %d times, want 2", got) + } +} + +func TestCachedAuthCodeDefaultTTLIsUnderServerWindow(t *testing.T) { + // Portal vendorAuthCode expiresIn is 120s and the code is one-shot. + // The unconsumed-cache window must stay under that server lifetime. + if DefaultAuthCodeTTL >= 120*time.Second { + t.Fatalf("DefaultAuthCodeTTL = %v, want less than the 120s portal expiresIn", DefaultAuthCodeTTL) + } + cache := NewCachedAuthCode(&countingProvider{}, 0) + if cache.ttl != DefaultAuthCodeTTL { + t.Fatalf("ttl = %v, want DefaultAuthCodeTTL %v", cache.ttl, DefaultAuthCodeTTL) + } +} + +func TestCachedAuthCodeNegativeTTLFallsBackToDefault(t *testing.T) { + cache := NewCachedAuthCode(&countingProvider{}, -time.Second) + if cache.ttl != DefaultAuthCodeTTL { + t.Fatalf("ttl = %v, want DefaultAuthCodeTTL %v", cache.ttl, DefaultAuthCodeTTL) + } +} + +func TestCachedAuthCodePropagatesUpstreamError(t *testing.T) { + wantErr := errors.New("token service down") + cache := NewCachedAuthCode(&countingProvider{err: wantErr}, time.Minute) + + _, err := cache.AuthCode(context.Background()) + if !errors.Is(err, wantErr) { + t.Fatalf("AuthCode() = %v, want it to wrap %v", err, wantErr) + } +} + +func TestCachedAuthCodeDoesNotCacheFailures(t *testing.T) { + provider := &countingProvider{err: errors.New("transient")} + cache := NewCachedAuthCode(provider, time.Minute) + + if _, err := cache.AuthCode(context.Background()); err == nil { + t.Fatal("AuthCode() = nil error, want failure") + } + + provider.mu.Lock() + provider.err = nil + provider.code = "recovered" + provider.mu.Unlock() + + code, err := cache.AuthCode(context.Background()) + if err != nil { + t.Fatalf("AuthCode() after recovery = %v", err) + } + if code != "recovered" { + t.Fatalf("AuthCode() = %q, want recovered (a failure must not be cached)", code) + } +} + +func TestCachedAuthCodeRejectsEmptyUpstreamCode(t *testing.T) { + // A provider that reports success with no code is a bug upstream; the + // cache must surface it instead of caching an unusable value. + cache := NewCachedAuthCode(AuthCodeFunc(func(context.Context) (string, error) { + return "", nil + }), time.Minute) + + if _, err := cache.AuthCode(context.Background()); !errors.Is(err, ErrNoAuthCode) { + t.Fatalf("AuthCode() = %v, want ErrNoAuthCode", err) + } +} + +func TestCachedAuthCodeInvalidateForcesRefetch(t *testing.T) { + provider := &countingProvider{} + cache := NewCachedAuthCode(provider, time.Hour) + + if _, err := cache.AuthCode(context.Background()); err != nil { + t.Fatalf("first AuthCode() = %v", err) + } + cache.Invalidate() + if _, err := cache.AuthCode(context.Background()); err != nil { + t.Fatalf("second AuthCode() = %v", err) + } + if got := provider.callCount(); got != 2 { + t.Fatalf("upstream called %d times, want 2 after Invalidate", got) + } +} + +func TestCachedAuthCodeWithoutProviderReportsMissingProvider(t *testing.T) { + cache := NewCachedAuthCode(nil, time.Minute) + if _, err := cache.AuthCode(context.Background()); !errors.Is(err, ErrNoAuthCodeProvider) { + t.Fatalf("AuthCode() = %v, want ErrNoAuthCodeProvider", err) + } +} + +func TestCachedAuthCodeIsSafeForConcurrentUse(t *testing.T) { + // The backend may ask for a code from a CGO callback while another + // operation is in flight, so concurrent access must not race. + provider := &countingProvider{code: "shared"} + cache := NewCachedAuthCode(provider, time.Hour) + + var wg sync.WaitGroup + for i := 0; i < 32; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if code, err := cache.AuthCode(context.Background()); err != nil || code != "shared" { + t.Errorf("AuthCode() = %q, %v; want shared, nil", code, err) + } + }() + } + wg.Wait() + + if got := provider.callCount(); got != 1 { + t.Fatalf("upstream called %d times, want 1", got) + } +} diff --git a/internal/msgcrypto/cipher_safechat.go b/internal/msgcrypto/cipher_safechat.go new file mode 100644 index 00000000..94268b64 --- /dev/null +++ b/internal/msgcrypto/cipher_safechat.go @@ -0,0 +1,159 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The constraint below must stay in sync with cipher_stub.go, which negates it +// verbatim. It encodes the platforms the vendor ships a libsafechat.a for: +// darwin and linux on amd64/arm64, plus windows/amd64. windows/arm64 is +// deliberately excluded because the vendor has not delivered that static +// library, and DWS does release that target. + +//go:build safechat && cgo && (((darwin || linux) && (amd64 || arm64)) || (windows && amd64)) + +package msgcrypto + +import ( + "context" + "errors" + "fmt" + "sync" + + safechat "safechat-go-sdk" +) + +// BackendVersion identifies the compiled-in vendor SDK. +const BackendVersion = "safechat " + safechat.Version + +// Available reports that this binary carries the SafeChat backend. +func Available() bool { return true } + +// safechatCipher adapts the vendor client to Cipher. +// +// The vendor client serialises its own C calls internally, so this type adds no +// further locking. Auth codes are minted only from AuthCodeHook, which the +// vendor SDK calls inside goProxy when a key is actually missing. +type safechatCipher struct { + client *safechat.Client + codes AuthCodeProvider + allowedHost string + + mu sync.Mutex + lastCodeErr error +} + +// newBackend starts the vendor client against cfg's keystore. +// +// A warm keystore serves encrypt and decrypt without a key request, so no +// authCode is fetched at open time. The hook runs only if goProxy fires. +func newBackend(ctx context.Context, cfg Config) (Cipher, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + var logf func(string, ...any) + if cfg.Debug { + logf = cfg.Logf + } + + c := &safechatCipher{codes: cfg.AuthCode, allowedHost: cfg.AllowedRedirectHost} + client, err := safechat.New(safechat.Config{ + DataPath: cfg.KeystoreDir, + UserID: cfg.UserID, + KeyServer: cfg.KeyServer, + MaxRetry: cfg.MaxRetry, + HTTPTimeout: cfg.HTTPTimeout, + Logger: newRedactingLogger(logf), + AuthCodeHook: c.authCodeHook, + }) + if err != nil { + if errors.Is(err, safechat.ErrAlreadyInitialized) { + return nil, ErrAlreadyOpen + } + return nil, fmt.Errorf("msgcrypto: start safechat backend: %w", err) + } + c.client = client + return c, nil +} + +// EncryptMessage encrypts plaintext and returns the vendor ciphertext. +func (c *safechatCipher) EncryptMessage(ctx context.Context, corpID, staffID string, plaintext []byte) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + c.setLastCodeErr(nil) + out, err := c.client.EncryptMsg(corpID, staffID, plaintext) + if err != nil { + return nil, c.explain("encrypt", corpID, err) + } + return out, nil +} + +// DecryptMessage decrypts a vendor ciphertext. +func (c *safechatCipher) DecryptMessage(ctx context.Context, corpID, staffID string, ciphertext []byte) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + c.setLastCodeErr(nil) + out, err := c.client.DecryptMsg(corpID, staffID, ciphertext) + if err != nil { + return nil, c.explain("decrypt", corpID, err) + } + return out, nil +} + +// Close releases the vendor client. +func (c *safechatCipher) Close() error { + c.client.Close() + return nil +} + +// authCodeHook is invoked from goProxy immediately before the key request. +// domain is compared locally and never forwarded to portal. The returned +// code is used once by the SDK and is not stored on the client. +func (c *safechatCipher) authCodeHook(corpID, domain string) (string, error) { + code, err := mintAuthCodeForProxy(c.codes, c.allowedHost, corpID, domain) + c.setLastCodeErr(err) + return code, err +} + +func (c *safechatCipher) setLastCodeErr(err error) { + c.mu.Lock() + c.lastCodeErr = err + c.mu.Unlock() +} + +func (c *safechatCipher) lastAuthCodeErr() error { + c.mu.Lock() + defer c.mu.Unlock() + return c.lastCodeErr +} + +// explain turns a vendor error into an actionable one, folding in a failed +// goProxy authCode mint and the admin-restricted case. +func (c *safechatCipher) explain(op, corpID string, opErr error) error { + if c.client.IsBlocked(corpID) { + return fmt.Errorf("msgcrypto: %s blocked: the organization's key is restricted by its administrator: %w", op, opErr) + } + + // A key fetch was needed but we had no usable code: that is the real + // cause, so report both. + if codeErr := c.lastAuthCodeErr(); codeErr != nil { + return fmt.Errorf("msgcrypto: %s failed and no usable auth code was available: %w (auth code error: %v)", op, opErr, codeErr) + } + + if errors.Is(opErr, safechat.ErrMaxRetryExceeded) { + invalidateAuthCode(c.codes) + return fmt.Errorf("msgcrypto: %s failed: key material never became available: %w", op, opErr) + } + + return fmt.Errorf("msgcrypto: %s failed: %w", op, opErr) +} diff --git a/internal/msgcrypto/cipher_safechat_test.go b/internal/msgcrypto/cipher_safechat_test.go new file mode 100644 index 00000000..69ab801f --- /dev/null +++ b/internal/msgcrypto/cipher_safechat_test.go @@ -0,0 +1,161 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Keep this constraint in sync with cipher_safechat.go. + +//go:build safechat && cgo && (((darwin || linux) && (amd64 || arm64)) || (windows && amd64)) + +package msgcrypto + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// These tests exercise the real vendor backend, which means they link +// libsafechat.a and initialise the C library. They never reach the key server: +// an authCode is only spent when a key is actually fetched, and asserting that +// encryption fails without one is exactly the behaviour we want pinned. + +func TestBackendIsReportedAvailable(t *testing.T) { + if !Available() { + t.Fatal("Available() = false in a safechat build") + } + if BackendVersion == "" { + t.Fatal("BackendVersion is empty in a safechat build") + } + if !strings.Contains(BackendVersion, "safechat") { + t.Fatalf("BackendVersion = %q, want it to name the vendor SDK", BackendVersion) + } +} + +func TestOpenInitialisesCLibraryAndClosesCleanly(t *testing.T) { + dir := filepath.Join(t.TempDir(), "keystore") + + cipher, err := Open(context.Background(), Config{ + KeystoreDir: dir, + AuthCode: StaticAuthCode("placeholder-code"), + KeyServer: "https://key.example.test", + }) + if err != nil { + t.Fatalf("Open() = %v, want the C library to initialise", err) + } + + if info, statErr := os.Stat(dir); statErr != nil || !info.IsDir() { + t.Fatalf("Open did not prepare the keystore dir: %v", statErr) + } + + if err := cipher.Close(); err != nil { + t.Fatalf("Close() = %v", err) + } + + // The slot must be free again, otherwise a second Open in the same + // process would be refused forever. + second, err := Open(context.Background(), Config{ + KeystoreDir: dir, + AuthCode: StaticAuthCode("placeholder-code"), + KeyServer: "https://key.example.test", + }) + if err != nil { + t.Fatalf("second Open() after Close = %v, want success", err) + } + if err := second.Close(); err != nil { + t.Fatalf("second Close() = %v", err) + } +} + +func TestOpenRefusesConcurrentSecondCipher(t *testing.T) { + dir := filepath.Join(t.TempDir(), "keystore") + first, err := Open(context.Background(), Config{ + KeystoreDir: dir, + AuthCode: StaticAuthCode("placeholder-code"), + KeyServer: "https://key.example.test", + }) + if err != nil { + t.Fatalf("Open() = %v", err) + } + defer first.Close() + + _, err = Open(context.Background(), Config{ + KeystoreDir: filepath.Join(t.TempDir(), "keystore"), + AuthCode: StaticAuthCode("placeholder-code"), + KeyServer: "https://key.example.test", + }) + if !errors.Is(err, ErrAlreadyOpen) { + t.Fatalf("second Open() = %v, want ErrAlreadyOpen (the C library keeps global state)", err) + } +} + +func TestOpenHonoursCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := Open(ctx, Config{ + KeystoreDir: filepath.Join(t.TempDir(), "keystore"), + AuthCode: StaticAuthCode("placeholder-code"), + KeyServer: "https://key.example.test", + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Open() with a cancelled context = %v, want context.Canceled", err) + } +} + +func TestEncryptWithoutUsableKeyReportsAuthCodeCause(t *testing.T) { + // A cold keystore forces a key request. With no reachable key server the + // operation must fail with a message that names the auth code, rather + // than a bare vendor return code. + cipher, err := Open(context.Background(), Config{ + KeystoreDir: filepath.Join(t.TempDir(), "keystore"), + AuthCode: AuthCodeFunc(func(context.Context) (string, error) { + return "", errors.New("no code available in test") + }), + KeyServer: "https://key.example.test", + }) + if err != nil { + t.Fatalf("Open() = %v", err) + } + defer cipher.Close() + + _, err = cipher.EncryptMessage(context.Background(), "test-corp", "test-staff", []byte("hello")) + if err == nil { + t.Skip("the environment served a key without an auth code; nothing to assert") + } + if !strings.Contains(err.Error(), "auth code") { + t.Fatalf("EncryptMessage() = %v, want the error to name the auth code cause", err) + } +} + +func TestCipherRejectsBadArgumentsBeforeCallingC(t *testing.T) { + cipher, err := Open(context.Background(), Config{ + KeystoreDir: filepath.Join(t.TempDir(), "keystore"), + AuthCode: StaticAuthCode("placeholder-code"), + KeyServer: "https://key.example.test", + }) + if err != nil { + t.Fatalf("Open() = %v", err) + } + defer cipher.Close() + + if _, err := cipher.EncryptMessage(context.Background(), "", "staff", []byte("x")); !errors.Is(err, ErrNoCorpID) { + t.Fatalf("EncryptMessage() with no corpID = %v, want ErrNoCorpID", err) + } + // The vendor SDK dereferences the first byte of the payload, so an empty + // slice must never reach it. + if _, err := cipher.DecryptMessage(context.Background(), "corp", "staff", nil); !errors.Is(err, ErrEmptyPayload) { + t.Fatalf("DecryptMessage() with no payload = %v, want ErrEmptyPayload", err) + } +} diff --git a/internal/msgcrypto/cipher_stub.go b/internal/msgcrypto/cipher_stub.go new file mode 100644 index 00000000..19eac0c0 --- /dev/null +++ b/internal/msgcrypto/cipher_stub.go @@ -0,0 +1,34 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The constraint below is the exact negation of the one in cipher_safechat.go; +// change both together. This file covers every default DWS build: the release +// binaries are cross-compiled with CGO_ENABLED=0, and windows/arm64 has no +// vendor static library even when the tag is set. + +//go:build !(safechat && cgo && (((darwin || linux) && (amd64 || arm64)) || (windows && amd64))) + +package msgcrypto + +import "context" + +// BackendVersion is empty because no backend is compiled in. +const BackendVersion = "" + +// Available reports that this binary has no SafeChat backend, so callers should +// not offer message encryption. +func Available() bool { return false } + +// newBackend always fails here. Open checks Available first, so this exists to +// keep the package compiling and to fail safe if that check is ever bypassed. +func newBackend(context.Context, Config) (Cipher, error) { return nil, ErrUnavailable } diff --git a/internal/msgcrypto/goproxy_authcode.go b/internal/msgcrypto/goproxy_authcode.go new file mode 100644 index 00000000..2e762456 --- /dev/null +++ b/internal/msgcrypto/goproxy_authcode.go @@ -0,0 +1,54 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package msgcrypto + +import "context" + +// mintAuthCodeForProxy is the goProxy-only mint path. domain is compared +// locally and never forwarded. A successful mint invalidates any unconsumed +// cache so a one-shot code cannot be reused. +func mintAuthCodeForProxy(codes AuthCodeProvider, allowedHost, corpID, domain string) (string, error) { + if err := matchRedirectHost(domain, allowedHost); err != nil { + return "", err + } + if codes == nil { + return "", ErrNoAuthCodeProvider + } + + var ( + code string + err error + ) + if provider, ok := codes.(CorpAuthCodeProvider); ok { + code, err = provider.AuthCodeForCorp(context.Background(), corpID) + } else { + code, err = codes.AuthCode(context.Background()) + } + if err != nil { + invalidateAuthCode(codes) + return "", err + } + if code == "" { + invalidateAuthCode(codes) + return "", ErrNoAuthCode + } + invalidateAuthCode(codes) + return code, nil +} + +func invalidateAuthCode(codes AuthCodeProvider) { + if invalidator, ok := codes.(interface{ Invalidate() }); ok { + invalidator.Invalidate() + } +} diff --git a/internal/msgcrypto/goproxy_authcode_test.go b/internal/msgcrypto/goproxy_authcode_test.go new file mode 100644 index 00000000..fb1f9c3b --- /dev/null +++ b/internal/msgcrypto/goproxy_authcode_test.go @@ -0,0 +1,99 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package msgcrypto + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +type corpCountingProvider struct { + mu sync.Mutex + calls []string + code string + err error +} + +func (p *corpCountingProvider) AuthCode(context.Context) (string, error) { + return p.AuthCodeForCorp(context.Background(), "") +} + +func (p *corpCountingProvider) AuthCodeForCorp(_ context.Context, corpID string) (string, error) { + p.mu.Lock() + defer p.mu.Unlock() + p.calls = append(p.calls, corpID) + if p.err != nil { + return "", p.err + } + if p.code != "" { + return p.code, nil + } + return "code-for-" + corpID, nil +} + +func TestMintAuthCodeForProxyUsesCorpProvider(t *testing.T) { + inner := &corpCountingProvider{} + code, err := mintAuthCodeForProxy(inner, "sso.anhei.test", "ding_corp", "https://sso.anhei.test/login") + if err != nil || code != "code-for-ding_corp" { + t.Fatalf("mint = %q, %v; want code-for-ding_corp, nil", code, err) + } + if len(inner.calls) != 1 || inner.calls[0] != "ding_corp" { + t.Fatalf("corpIDs = %v, want [ding_corp]", inner.calls) + } +} + +func TestMintAuthCodeForProxyInvalidatesUnconsumedCache(t *testing.T) { + inner := &countingProvider{code: "once"} + cache := NewCachedAuthCode(inner, time.Hour) + + if _, err := cache.AuthCode(context.Background()); err != nil { + t.Fatalf("seed cache: %v", err) + } + if got := inner.callCount(); got != 1 { + t.Fatalf("seed fetches = %d, want 1", got) + } + + code, err := mintAuthCodeForProxy(cache, "sso.anhei.test", "ding_corp", "https://sso.anhei.test/login") + if err != nil || code != "once" { + t.Fatalf("mint = %q, %v; want once, nil", code, err) + } + + // Cache was invalidated after spend; next mint hits upstream again. + if _, err := mintAuthCodeForProxy(cache, "sso.anhei.test", "ding_corp", "sso.anhei.test"); err != nil { + t.Fatalf("second mint: %v", err) + } + if got := inner.callCount(); got != 2 { + t.Fatalf("upstream calls = %d, want 2 (seed reused once, then refetch)", got) + } +} + +func TestMintAuthCodeForProxyRejectsDomainMismatchWithoutFetching(t *testing.T) { + inner := &corpCountingProvider{code: "once"} + _, err := mintAuthCodeForProxy(inner, "sso.anhei.test", "ding_corp", "evil.example.test") + if !errors.Is(err, ErrRedirectHostMismatch) { + t.Fatalf("mint = %v, want ErrRedirectHostMismatch", err) + } + if got := len(inner.calls); got != 0 { + t.Fatalf("upstream called %d times on domain mismatch, want 0", got) + } +} + +func TestMintAuthCodeForProxyRequiresProvider(t *testing.T) { + if _, err := mintAuthCodeForProxy(nil, "", "ding_corp", ""); !errors.Is(err, ErrNoAuthCodeProvider) { + t.Fatalf("mint = %v, want ErrNoAuthCodeProvider", err) + } +} diff --git a/internal/msgcrypto/keyserver.go b/internal/msgcrypto/keyserver.go new file mode 100644 index 00000000..70bd6e3c --- /dev/null +++ b/internal/msgcrypto/keyserver.go @@ -0,0 +1,83 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package msgcrypto + +import ( + "fmt" + "net" + "net/url" + "strings" +) + +// validateKeyServer requires an HTTPS URL with a host so the vendor C +// library cannot pick the key-request destination. +func validateKeyServer(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return ErrNoKeyServer + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" || u.Scheme == "" { + return fmt.Errorf("%w: %q", ErrInvalidKeyServer, raw) + } + if !strings.EqualFold(u.Scheme, "https") { + return fmt.Errorf("%w: %q", ErrKeyServerNotHTTPS, raw) + } + if hostnameOf(raw) == "" { + return fmt.Errorf("%w: %q", ErrInvalidKeyServer, raw) + } + return nil +} + +// matchRedirectHost compares the goProxy domain to AllowedRedirectHost. +// Both sides are reduced to a hostname. An empty domain or an empty +// allowed host skips the check; the domain is never sent to portal. +func matchRedirectHost(domain, allowed string) error { + domain = strings.TrimSpace(domain) + allowed = strings.TrimSpace(allowed) + if domain == "" || allowed == "" { + return nil + } + got := hostnameOf(domain) + want := hostnameOf(allowed) + if got == "" || want == "" || got != want { + return fmt.Errorf("%w: got %q, want %q", ErrRedirectHostMismatch, got, want) + } + return nil +} + +// hostnameOf returns the lower-cased hostname of a URL, host:port, or bare +// host. Path, query, userinfo and port are ignored. +func hostnameOf(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if strings.Contains(raw, "://") { + u, err := url.Parse(raw) + if err == nil { + if host := strings.ToLower(u.Hostname()); host != "" { + return host + } + } + } + candidate := raw + if i := strings.IndexAny(candidate, "/?"); i >= 0 { + candidate = candidate[:i] + } + if host, _, err := net.SplitHostPort(candidate); err == nil { + return strings.ToLower(host) + } + return strings.ToLower(candidate) +} diff --git a/internal/msgcrypto/keyserver_test.go b/internal/msgcrypto/keyserver_test.go new file mode 100644 index 00000000..e1135716 --- /dev/null +++ b/internal/msgcrypto/keyserver_test.go @@ -0,0 +1,77 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package msgcrypto + +import ( + "errors" + "testing" +) + +func TestValidateKeyServerAcceptsHTTPS(t *testing.T) { + if err := validateKeyServer("https://key.example.test/v1"); err != nil { + t.Fatalf("validateKeyServer() = %v, want nil", err) + } +} + +func TestValidateKeyServerRejectsEmpty(t *testing.T) { + if err := validateKeyServer(" "); !errors.Is(err, ErrNoKeyServer) { + t.Fatalf("validateKeyServer() = %v, want ErrNoKeyServer", err) + } +} + +func TestValidateKeyServerRejectsHTTP(t *testing.T) { + if err := validateKeyServer("http://key.example.test"); !errors.Is(err, ErrKeyServerNotHTTPS) { + t.Fatalf("validateKeyServer() = %v, want ErrKeyServerNotHTTPS", err) + } +} + +func TestValidateKeyServerRejectsBareHost(t *testing.T) { + if err := validateKeyServer("key.example.test"); !errors.Is(err, ErrInvalidKeyServer) { + t.Fatalf("validateKeyServer() = %v, want ErrInvalidKeyServer", err) + } +} + +func TestMatchRedirectHostComparesHostOnly(t *testing.T) { + if err := matchRedirectHost("https://sso.anhei.test:443/login", "https://sso.anhei.test/path"); err != nil { + t.Fatalf("matchRedirectHost() = %v, want nil", err) + } + if err := matchRedirectHost("sso.anhei.test", "https://sso.anhei.test"); err != nil { + t.Fatalf("bare host match = %v, want nil", err) + } +} + +func TestMatchRedirectHostSkipsWhenEitherSideEmpty(t *testing.T) { + if err := matchRedirectHost("", "https://sso.anhei.test"); err != nil { + t.Fatalf("empty domain = %v, want nil", err) + } + if err := matchRedirectHost("sso.anhei.test", ""); err != nil { + t.Fatalf("empty allowed host = %v, want nil", err) + } +} + +func TestMatchRedirectHostRejectsMismatch(t *testing.T) { + err := matchRedirectHost("evil.example.test", "https://sso.anhei.test") + if !errors.Is(err, ErrRedirectHostMismatch) { + t.Fatalf("matchRedirectHost() = %v, want ErrRedirectHostMismatch", err) + } +} + +func TestHostnameOfStripsPortAndPath(t *testing.T) { + if got := hostnameOf("https://SSO.Example.TEST:8443/login?x=1"); got != "sso.example.test" { + t.Fatalf("hostnameOf(url) = %q", got) + } + if got := hostnameOf("SSO.Example.TEST:8443/login"); got != "sso.example.test" { + t.Fatalf("hostnameOf(hostport) = %q", got) + } +} diff --git a/internal/msgcrypto/logger.go b/internal/msgcrypto/logger.go new file mode 100644 index 00000000..7f5a91ad --- /dev/null +++ b/internal/msgcrypto/logger.go @@ -0,0 +1,96 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package msgcrypto + +import "fmt" + +// redactingLogger satisfies the vendor SDK's logger interface while making it +// impossible for the SDK to leak secrets into DWS output. +// +// This matters because the vendor SDK logs, at debug level, both the authCode +// it sends to the key server and the raw key-server response body, which +// carries key material. Rather than trying to enumerate and pattern-match +// every sensitive field, we drop every string argument and keep only its +// length. Numeric and boolean arguments pass through, so operators still get +// the useful diagnostics: HTTP status, payload sizes, timings and return +// codes. The format string is preserved so it stays clear which field was +// elided. +type redactingLogger struct { + logf func(format string, args ...any) +} + +// newRedactingLogger returns a logger that forwards to logf, or nil when logf +// is nil so the SDK skips logging entirely. +func newRedactingLogger(logf func(format string, args ...any)) *redactingLogger { + if logf == nil { + return nil + } + return &redactingLogger{logf: logf} +} + +// Debug forwards a redacted debug line. +func (l *redactingLogger) Debug(msg string, args ...interface{}) { l.emit("debug", msg, args) } + +// Info forwards a redacted info line. +func (l *redactingLogger) Info(msg string, args ...interface{}) { l.emit("info", msg, args) } + +// Error forwards a redacted error line. +func (l *redactingLogger) Error(msg string, args ...interface{}) { l.emit("error", msg, args) } + +// emit rewrites args so no string value survives, then forwards the line. +func (l *redactingLogger) emit(level, msg string, args []interface{}) { + if l == nil || l.logf == nil { + return + } + l.logf("safechat[%s] "+msg, append([]any{level}, redactArgs(args)...)...) +} + +// redactArgs replaces every string-like argument with a length marker and +// leaves other kinds intact. +func redactArgs(args []interface{}) []any { + out := make([]any, 0, len(args)) + for _, arg := range args { + out = append(out, redactArg(arg)) + } + return out +} + +// redactArg elides a single argument's contents when it could carry a secret. +// Strings and byte slices are reduced to their length; errors are reduced to +// their type so a wrapped body preview cannot slip through; everything else +// (numbers, booleans, durations) is kept because it cannot carry key material. +func redactArg(arg any) any { + switch v := arg.(type) { + case string: + return redactedValue(len(v)) + case []byte: + return redactedValue(len(v)) + case fmt.Stringer: + // time.Duration and friends are Stringers, but so are opaque types + // that may embed a payload. Keep durations, elide the rest. + if _, ok := arg.(interface{ Nanoseconds() int64 }); ok { + return v.String() + } + return redactedValue(len(v.String())) + case error: + return fmt.Sprintf("<%T redacted>", v) + default: + return v + } +} + +// redactedValue renders the placeholder used in place of elided content. +func redactedValue(n int) string { + return fmt.Sprintf("", n) +} diff --git a/internal/msgcrypto/logger_test.go b/internal/msgcrypto/logger_test.go new file mode 100644 index 00000000..7b86cba0 --- /dev/null +++ b/internal/msgcrypto/logger_test.go @@ -0,0 +1,157 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package msgcrypto + +import ( + "errors" + "fmt" + "strings" + "testing" + "time" +) + +// captureLogf collects emitted lines for assertions. +func captureLogf(lines *[]string) func(string, ...any) { + return func(format string, args ...any) { + *lines = append(*lines, fmt.Sprintf(format, args...)) + } +} + +func TestNewRedactingLoggerReturnsNilWhenSinkIsNil(t *testing.T) { + // A nil logger makes the vendor SDK skip logging entirely, which is the + // safe default because it logs the authCode at debug level. + if got := newRedactingLogger(nil); got != nil { + t.Fatalf("newRedactingLogger(nil) = %v, want nil", got) + } +} + +func TestRedactingLoggerHidesAuthCode(t *testing.T) { + var lines []string + logger := newRedactingLogger(captureLogf(&lines)) + + // This mirrors the vendor SDK's own debug line, which prints the code. + const secret = "abc123authcode" + logger.Debug("Code (auth_token, length=%d): %s", len(secret), secret) + + if len(lines) != 1 { + t.Fatalf("got %d lines, want 1", len(lines)) + } + if strings.Contains(lines[0], secret) { + t.Fatalf("log line leaked the auth code: %q", lines[0]) + } + if !strings.Contains(lines[0], "redacted") { + t.Fatalf("log line = %q, want a redaction marker", lines[0]) + } +} + +func TestRedactingLoggerHidesKeyServerResponseBody(t *testing.T) { + var lines []string + logger := newRedactingLogger(captureLogf(&lines)) + + body := `{"key":"BASE64KEYMATERIAL==","keyVersion":3}` + logger.Debug("Body (length=%d): %s", len(body), body) + + if strings.Contains(lines[0], "BASE64KEYMATERIAL") { + t.Fatalf("log line leaked key material: %q", lines[0]) + } +} + +func TestRedactingLoggerHidesByteSlices(t *testing.T) { + var lines []string + logger := newRedactingLogger(captureLogf(&lines)) + + logger.Info("payload=%s", []byte("plaintext-message")) + + if strings.Contains(lines[0], "plaintext-message") { + t.Fatalf("log line leaked a byte payload: %q", lines[0]) + } +} + +func TestRedactingLoggerHidesErrorText(t *testing.T) { + var lines []string + logger := newRedactingLogger(captureLogf(&lines)) + + // Vendor error strings can embed a response body preview. + logger.Error("request failed: %v", errors.New(`server said {"key":"LEAKED"}`)) + + if strings.Contains(lines[0], "LEAKED") { + t.Fatalf("log line leaked error contents: %q", lines[0]) + } +} + +func TestRedactingLoggerKeepsDiagnosticNumbers(t *testing.T) { + var lines []string + logger := newRedactingLogger(captureLogf(&lines)) + + logger.Debug("Status: %d (took %s), size=%d", 503, 1500*time.Millisecond, 4096) + + line := lines[0] + for _, want := range []string{"503", "1.5s", "4096"} { + if !strings.Contains(line, want) { + t.Fatalf("log line = %q, want it to keep %q for diagnostics", line, want) + } + } +} + +func TestRedactingLoggerLabelsLevel(t *testing.T) { + var lines []string + logger := newRedactingLogger(captureLogf(&lines)) + + logger.Debug("d") + logger.Info("i") + logger.Error("e") + + if len(lines) != 3 { + t.Fatalf("got %d lines, want 3", len(lines)) + } + for i, want := range []string{"debug", "info", "error"} { + if !strings.Contains(lines[i], want) { + t.Fatalf("line %d = %q, want level %q", i, lines[i], want) + } + } +} + +func TestRedactingLoggerToleratesNilSinkAtEmit(t *testing.T) { + // Guard against a partially constructed logger being used. + var logger *redactingLogger + logger.Debug("must not panic %s", "value") +} + +func TestRedactArgKeepsDurations(t *testing.T) { + if got := redactArg(2 * time.Second); got != "2s" { + t.Fatalf("redactArg(2s) = %v, want 2s", got) + } +} + +func TestRedactArgElidesStrings(t *testing.T) { + got, ok := redactArg("secret").(string) + if !ok { + t.Fatalf("redactArg returned %T, want string", got) + } + if strings.Contains(got, "secret") { + t.Fatalf("redactArg = %q, want the value elided", got) + } + if !strings.Contains(got, "len=6") { + t.Fatalf("redactArg = %q, want the length preserved", got) + } +} + +func TestRedactArgPassesThroughNumbers(t *testing.T) { + if got := redactArg(42); got != 42 { + t.Fatalf("redactArg(42) = %v, want 42", got) + } + if got := redactArg(true); got != true { + t.Fatalf("redactArg(true) = %v, want true", got) + } +} diff --git a/internal/msgcrypto/msgcrypto.go b/internal/msgcrypto/msgcrypto.go new file mode 100644 index 00000000..26409049 --- /dev/null +++ b/internal/msgcrypto/msgcrypto.go @@ -0,0 +1,297 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package msgcrypto wraps the third-party SafeChat SDK so DWS can decrypt +// DingTalk messages that an organization has encrypted with its own key +// material, and encrypt outbound ones. +// +// The SafeChat backend links a prebuilt C static library and therefore needs +// CGO. Because DWS ships CGO-free cross-compiled release binaries, the backend +// is compiled only under the "safechat" build tag: +// +// CGO_ENABLED=1 go build -tags safechat ./cmd +// +// Every other build gets a stub whose constructor fails with ErrUnavailable, +// so callers must always handle that error rather than assume the capability +// exists. Use Available to branch before offering the feature to a user. +// +// Key material is fetched from the vendor key server on demand, which requires +// a DingTalk 免登 authCode supplied through AuthCodeProvider. DWS does not mint +// that code itself; the caller injects a provider. +package msgcrypto + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sync" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" +) + +// Errors reported by this package. Callers are expected to test for +// ErrUnavailable explicitly, because it is the normal outcome on every build +// that does not enable the safechat tag. +var ( + // ErrUnavailable means this binary was built without the SafeChat + // backend, or for a platform the vendor does not ship a static library + // for (notably windows/arm64). + ErrUnavailable = errors.New("msgcrypto: SafeChat backend not built into this binary") + + // ErrAlreadyOpen means a Cipher is already open. The underlying C + // library keeps global state, so only one may exist per process. + ErrAlreadyOpen = errors.New("msgcrypto: a cipher is already open in this process") + + // ErrClosed is returned by a Cipher whose Close has already run. + ErrClosed = errors.New("msgcrypto: cipher is closed") + + // ErrNoAuthCodeProvider means Config.AuthCode was nil. Without it the + // backend cannot fetch or rotate key material. + ErrNoAuthCodeProvider = errors.New("msgcrypto: config.AuthCode is required") + + // ErrEmptyPayload means an encrypt or decrypt call got no bytes. The + // vendor SDK rejects empty input, so we reject it earlier with a + // clearer message. + ErrEmptyPayload = errors.New("msgcrypto: payload is empty") + + // ErrNoCorpID means the caller omitted the organization id, which + // selects the key and therefore cannot be defaulted. + ErrNoCorpID = errors.New("msgcrypto: corpID is required") + + // ErrNoKeyServer means Config.KeyServer was empty. The vendor C + // library would otherwise pick the key-request destination. + ErrNoKeyServer = errors.New("msgcrypto: config.KeyServer is required") + + // ErrInvalidKeyServer means Config.KeyServer is not a usable URL. + ErrInvalidKeyServer = errors.New("msgcrypto: config.KeyServer is not a valid URL") + + // ErrKeyServerNotHTTPS means Config.KeyServer is not https. + ErrKeyServerNotHTTPS = errors.New("msgcrypto: config.KeyServer must be an https URL") + + // ErrRedirectHostMismatch means the domain goProxy received does not + // match Config.AllowedRedirectHost. The domain is never sent to portal. + ErrRedirectHostMismatch = errors.New("msgcrypto: goProxy domain host does not match AllowedRedirectHost") +) + +// keystoreDirPerm keeps the key cache owner-only. The directory holds +// organization key material, so it must not be group- or world-readable. +const keystoreDirPerm fs.FileMode = 0o700 + +// DefaultKeystoreDir returns the default key cache directory, +// ~/.dws/safechat/keystore, honouring DWS_CONFIG_DIR like the rest of DWS. +func DefaultKeystoreDir() string { + return filepath.Join(config.DefaultConfigDir(), "safechat", "keystore") +} + +// Cipher encrypts and decrypts message payloads for one organization at a +// time. Implementations are safe for concurrent use. +type Cipher interface { + // EncryptMessage encrypts plaintext for corpID/staffID and returns the + // vendor ciphertext envelope. + EncryptMessage(ctx context.Context, corpID, staffID string, plaintext []byte) ([]byte, error) + + // DecryptMessage decrypts a vendor ciphertext envelope. + DecryptMessage(ctx context.Context, corpID, staffID string, ciphertext []byte) ([]byte, error) + + // Close releases the backend and frees the process-wide slot so a later + // Open can succeed. Calling it twice is safe. + Close() error +} + +// Config parameterises Open. +type Config struct { + // KeystoreDir is where fetched keys are cached. Defaults to + // DefaultKeystoreDir. It is created with 0700 if missing. + KeystoreDir string + + // UserID is an opaque local identifier. The vendor SDK stores it but + // does not use it for key selection; leave empty to let the SDK + // generate one. + UserID string + + // AuthCode supplies the DingTalk 免登 authCode used to authenticate key + // requests. Required. The backend calls it only from the vendor goProxy + // callback (cold keystore or key-version rotation), never on every + // encrypt/decrypt. + AuthCode AuthCodeProvider + + // KeyServer is the HTTPS URL of the vendor key service. Required: it + // replaces the host the closed-source C library would otherwise pick. + KeyServer string + + // AllowedRedirectHost, when set, is compared to the domain the C + // library passes into goProxy. A mismatch fails that key fetch. It is + // a local check only; the domain is never sent to portal. + AllowedRedirectHost string + + // MaxRetry bounds retries while a key is still being fetched. Zero + // selects the vendor default. + MaxRetry int + + // HTTPTimeout bounds each key request. Zero selects the vendor default. + HTTPTimeout time.Duration + + // Debug enables backend logging through a redacting logger. Off by + // default because the vendor SDK logs the authCode and raw key-server + // responses at debug level. + Debug bool + + // Logf receives already-redacted backend log lines when Debug is set. + // Nil discards them. + Logf func(format string, args ...any) +} + +// withDefaults returns cfg with empty optional fields filled in. +func (cfg Config) withDefaults() Config { + if cfg.KeystoreDir == "" { + cfg.KeystoreDir = DefaultKeystoreDir() + } + return cfg +} + +// validate reports whether cfg carries everything the backend needs. +func (cfg Config) validate() error { + if cfg.AuthCode == nil { + return ErrNoAuthCodeProvider + } + if cfg.KeystoreDir == "" { + return errors.New("msgcrypto: config.KeystoreDir resolved to an empty path") + } + if err := validateKeyServer(cfg.KeyServer); err != nil { + return err + } + return nil +} + +// prepareKeystore creates dir if needed and makes sure it is owner-only. +// An existing directory with looser bits is tightened, because it caches key +// material. +func prepareKeystore(dir string) error { + if err := os.MkdirAll(dir, keystoreDirPerm); err != nil { + return fmt.Errorf("msgcrypto: create keystore dir: %w", err) + } + info, err := os.Stat(dir) + if err != nil { + return fmt.Errorf("msgcrypto: stat keystore dir: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("msgcrypto: keystore path %s is not a directory", dir) + } + // Windows does not model POSIX bits, so only tighten where they apply. + if runtimeSupportsPOSIXPerm && info.Mode().Perm() != keystoreDirPerm { + if err := os.Chmod(dir, keystoreDirPerm); err != nil { + return fmt.Errorf("msgcrypto: restrict keystore dir permissions: %w", err) + } + } + return nil +} + +// process holds the single-instance guard. The vendor C library keeps global +// state, so a second concurrent Cipher would corrupt it. +var process struct { + mu sync.Mutex + open bool +} + +// Open validates cfg, prepares the keystore and starts the backend. +// +// It returns ErrUnavailable when the backend was not compiled in, so callers +// can degrade gracefully. Only one Cipher may be open per process; Close frees +// the slot. +func Open(ctx context.Context, cfg Config) (Cipher, error) { + cfg = cfg.withDefaults() + if err := cfg.validate(); err != nil { + return nil, err + } + if !Available() { + return nil, ErrUnavailable + } + if err := prepareKeystore(cfg.KeystoreDir); err != nil { + return nil, err + } + + process.mu.Lock() + defer process.mu.Unlock() + if process.open { + return nil, ErrAlreadyOpen + } + backend, err := newBackend(ctx, cfg) + if err != nil { + return nil, err + } + process.open = true + return &trackedCipher{backend: backend}, nil +} + +// trackedCipher releases the process-wide slot when the wrapped backend closes. +type trackedCipher struct { + mu sync.Mutex + backend Cipher +} + +// EncryptMessage validates the payload and delegates to the backend. +func (c *trackedCipher) EncryptMessage(ctx context.Context, corpID, staffID string, plaintext []byte) ([]byte, error) { + backend, err := c.live(corpID, plaintext) + if err != nil { + return nil, err + } + return backend.EncryptMessage(ctx, corpID, staffID, plaintext) +} + +// DecryptMessage validates the payload and delegates to the backend. +func (c *trackedCipher) DecryptMessage(ctx context.Context, corpID, staffID string, ciphertext []byte) ([]byte, error) { + backend, err := c.live(corpID, ciphertext) + if err != nil { + return nil, err + } + return backend.DecryptMessage(ctx, corpID, staffID, ciphertext) +} + +// live returns the backend after checking the cipher is open and the arguments +// are usable. +func (c *trackedCipher) live(corpID string, payload []byte) (Cipher, error) { + if corpID == "" { + return nil, ErrNoCorpID + } + if len(payload) == 0 { + return nil, ErrEmptyPayload + } + c.mu.Lock() + defer c.mu.Unlock() + if c.backend == nil { + return nil, ErrClosed + } + return c.backend, nil +} + +// Close closes the backend once and releases the process-wide slot. +func (c *trackedCipher) Close() error { + c.mu.Lock() + backend := c.backend + c.backend = nil + c.mu.Unlock() + if backend == nil { + return nil + } + + err := backend.Close() + + process.mu.Lock() + process.open = false + process.mu.Unlock() + return err +} diff --git a/internal/msgcrypto/msgcrypto_test.go b/internal/msgcrypto/msgcrypto_test.go new file mode 100644 index 00000000..2bcb7a44 --- /dev/null +++ b/internal/msgcrypto/msgcrypto_test.go @@ -0,0 +1,345 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package msgcrypto + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +// fakeCipher stands in for a backend so the wrapper logic can be tested on +// every platform, with or without the safechat tag. +type fakeCipher struct { + mu sync.Mutex + encrypted [][]byte + decrypted [][]byte + closeCount int + closeErr error +} + +func (f *fakeCipher) EncryptMessage(_ context.Context, _, _ string, plaintext []byte) ([]byte, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.encrypted = append(f.encrypted, plaintext) + return []byte("cipher:" + string(plaintext)), nil +} + +func (f *fakeCipher) DecryptMessage(_ context.Context, _, _ string, ciphertext []byte) ([]byte, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.decrypted = append(f.decrypted, ciphertext) + return []byte(strings.TrimPrefix(string(ciphertext), "cipher:")), nil +} + +func (f *fakeCipher) Close() error { + f.mu.Lock() + defer f.mu.Unlock() + f.closeCount++ + return f.closeErr +} + +// newTrackedForTest wraps backend and claims the process slot the same way +// Open does, so slot release can be asserted. +func newTrackedForTest(t *testing.T, backend Cipher) *trackedCipher { + t.Helper() + process.mu.Lock() + process.open = true + process.mu.Unlock() + t.Cleanup(func() { + process.mu.Lock() + process.open = false + process.mu.Unlock() + }) + return &trackedCipher{backend: backend} +} + +func TestConfigWithDefaultsFillsKeystoreDir(t *testing.T) { + cfg := Config{}.withDefaults() + if cfg.KeystoreDir == "" { + t.Fatal("withDefaults left KeystoreDir empty") + } + if want := DefaultKeystoreDir(); cfg.KeystoreDir != want { + t.Fatalf("KeystoreDir = %q, want %q", cfg.KeystoreDir, want) + } +} + +func TestConfigWithDefaultsKeepsExplicitKeystoreDir(t *testing.T) { + cfg := Config{KeystoreDir: "/custom/keys"}.withDefaults() + if cfg.KeystoreDir != "/custom/keys" { + t.Fatalf("KeystoreDir = %q, want /custom/keys", cfg.KeystoreDir) + } +} + +func TestDefaultKeystoreDirHonoursConfigDirOverride(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + got := DefaultKeystoreDir() + if !strings.HasSuffix(got, filepath.Join("safechat", "keystore")) { + t.Fatalf("DefaultKeystoreDir() = %q, want it to end with safechat/keystore", got) + } + if !strings.HasPrefix(got, os.Getenv("DWS_CONFIG_DIR")) { + t.Fatalf("DefaultKeystoreDir() = %q, want it under DWS_CONFIG_DIR", got) + } +} + +func TestConfigValidateRequiresAuthCodeProvider(t *testing.T) { + err := Config{KeystoreDir: "/tmp/keys"}.validate() + if !errors.Is(err, ErrNoAuthCodeProvider) { + t.Fatalf("validate() = %v, want ErrNoAuthCodeProvider", err) + } +} + +func TestConfigValidateAcceptsCompleteConfig(t *testing.T) { + cfg := Config{ + KeystoreDir: "/tmp/keys", + AuthCode: StaticAuthCode("code"), + KeyServer: "https://key.example.test", + } + if err := cfg.validate(); err != nil { + t.Fatalf("validate() = %v, want nil", err) + } +} + +func TestConfigValidateRequiresKeyServer(t *testing.T) { + err := Config{KeystoreDir: "/tmp/keys", AuthCode: StaticAuthCode("code")}.validate() + if !errors.Is(err, ErrNoKeyServer) { + t.Fatalf("validate() = %v, want ErrNoKeyServer", err) + } +} + +func TestConfigValidateRejectsHTTPKeyServer(t *testing.T) { + err := Config{ + KeystoreDir: "/tmp/keys", + AuthCode: StaticAuthCode("code"), + KeyServer: "http://key.example.test", + }.validate() + if !errors.Is(err, ErrKeyServerNotHTTPS) { + t.Fatalf("validate() = %v, want ErrKeyServerNotHTTPS", err) + } +} + +func TestPrepareKeystoreCreatesOwnerOnlyDir(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested", "keystore") + if err := prepareKeystore(dir); err != nil { + t.Fatalf("prepareKeystore() = %v", err) + } + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat: %v", err) + } + if !info.IsDir() { + t.Fatal("prepareKeystore did not create a directory") + } + if runtime.GOOS == "windows" { + return + } + if got := info.Mode().Perm(); got != keystoreDirPerm { + t.Fatalf("perm = %#o, want %#o", got, keystoreDirPerm) + } +} + +func TestPrepareKeystoreTightensLoosePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permission bits are not modelled on Windows") + } + dir := filepath.Join(t.TempDir(), "keystore") + if err := os.MkdirAll(dir, fs.FileMode(0o755)); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := prepareKeystore(dir); err != nil { + t.Fatalf("prepareKeystore() = %v", err) + } + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat: %v", err) + } + if got := info.Mode().Perm(); got != keystoreDirPerm { + t.Fatalf("perm = %#o, want %#o (key material must stay owner-only)", got, keystoreDirPerm) + } +} + +func TestPrepareKeystoreRejectsFilePath(t *testing.T) { + path := filepath.Join(t.TempDir(), "keystore") + if err := os.WriteFile(path, []byte("not a dir"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + err := prepareKeystore(path) + if err == nil { + t.Fatal("prepareKeystore() = nil, want an error for a non-directory path") + } +} + +func TestOpenRejectsMissingAuthCodeProviderBeforeTouchingDisk(t *testing.T) { + dir := filepath.Join(t.TempDir(), "keystore") + _, err := Open(context.Background(), Config{KeystoreDir: dir}) + if !errors.Is(err, ErrNoAuthCodeProvider) { + t.Fatalf("Open() = %v, want ErrNoAuthCodeProvider", err) + } + if _, statErr := os.Stat(dir); !os.IsNotExist(statErr) { + t.Fatal("Open created the keystore dir despite an invalid config") + } +} + +func TestOpenRejectsMissingKeyServerBeforeTouchingDisk(t *testing.T) { + dir := filepath.Join(t.TempDir(), "keystore") + _, err := Open(context.Background(), Config{ + KeystoreDir: dir, + AuthCode: StaticAuthCode("code"), + }) + if !errors.Is(err, ErrNoKeyServer) { + t.Fatalf("Open() = %v, want ErrNoKeyServer", err) + } + if _, statErr := os.Stat(dir); !os.IsNotExist(statErr) { + t.Fatal("Open created the keystore dir despite a missing KeyServer") + } +} + +func TestOpenWithoutBackendReportsUnavailable(t *testing.T) { + if Available() { + t.Skip("this binary has the safechat backend compiled in") + } + _, err := Open(context.Background(), Config{ + KeystoreDir: filepath.Join(t.TempDir(), "keystore"), + AuthCode: StaticAuthCode("code"), + KeyServer: "https://key.example.test", + }) + if !errors.Is(err, ErrUnavailable) { + t.Fatalf("Open() = %v, want ErrUnavailable", err) + } +} + +func TestAvailableAgreesWithBackendVersion(t *testing.T) { + if Available() != (BackendVersion != "") { + t.Fatalf("Available() = %v but BackendVersion = %q; they must agree", Available(), BackendVersion) + } +} + +func TestTrackedCipherRoundTripsThroughBackend(t *testing.T) { + backend := &fakeCipher{} + cipher := newTrackedForTest(t, backend) + + ciphertext, err := cipher.EncryptMessage(context.Background(), "corp", "staff", []byte("hello")) + if err != nil { + t.Fatalf("EncryptMessage() = %v", err) + } + plaintext, err := cipher.DecryptMessage(context.Background(), "corp", "staff", ciphertext) + if err != nil { + t.Fatalf("DecryptMessage() = %v", err) + } + if string(plaintext) != "hello" { + t.Fatalf("round trip = %q, want hello", plaintext) + } +} + +func TestTrackedCipherRejectsEmptyCorpID(t *testing.T) { + cipher := newTrackedForTest(t, &fakeCipher{}) + if _, err := cipher.EncryptMessage(context.Background(), "", "staff", []byte("x")); !errors.Is(err, ErrNoCorpID) { + t.Fatalf("EncryptMessage() = %v, want ErrNoCorpID", err) + } + if _, err := cipher.DecryptMessage(context.Background(), "", "staff", []byte("x")); !errors.Is(err, ErrNoCorpID) { + t.Fatalf("DecryptMessage() = %v, want ErrNoCorpID", err) + } +} + +func TestTrackedCipherRejectsEmptyPayload(t *testing.T) { + cipher := newTrackedForTest(t, &fakeCipher{}) + if _, err := cipher.EncryptMessage(context.Background(), "corp", "staff", nil); !errors.Is(err, ErrEmptyPayload) { + t.Fatalf("EncryptMessage() = %v, want ErrEmptyPayload", err) + } + if _, err := cipher.DecryptMessage(context.Background(), "corp", "staff", []byte{}); !errors.Is(err, ErrEmptyPayload) { + t.Fatalf("DecryptMessage() = %v, want ErrEmptyPayload", err) + } +} + +func TestTrackedCipherRejectsUseAfterClose(t *testing.T) { + backend := &fakeCipher{} + cipher := newTrackedForTest(t, backend) + if err := cipher.Close(); err != nil { + t.Fatalf("Close() = %v", err) + } + if _, err := cipher.EncryptMessage(context.Background(), "corp", "staff", []byte("x")); !errors.Is(err, ErrClosed) { + t.Fatalf("EncryptMessage() after Close = %v, want ErrClosed", err) + } +} + +func TestTrackedCipherCloseIsIdempotentAndClosesBackendOnce(t *testing.T) { + backend := &fakeCipher{} + cipher := newTrackedForTest(t, backend) + for i := 0; i < 3; i++ { + if err := cipher.Close(); err != nil { + t.Fatalf("Close() #%d = %v", i+1, err) + } + } + if backend.closeCount != 1 { + t.Fatalf("backend Close called %d times, want exactly 1", backend.closeCount) + } +} + +func TestTrackedCipherCloseReleasesProcessSlot(t *testing.T) { + cipher := newTrackedForTest(t, &fakeCipher{}) + if err := cipher.Close(); err != nil { + t.Fatalf("Close() = %v", err) + } + process.mu.Lock() + open := process.open + process.mu.Unlock() + if open { + t.Fatal("Close did not release the process slot, so a later Open would fail") + } +} + +func TestTrackedCipherClosePropagatesBackendError(t *testing.T) { + wantErr := errors.New("backend close failed") + cipher := newTrackedForTest(t, &fakeCipher{closeErr: wantErr}) + if err := cipher.Close(); !errors.Is(err, wantErr) { + t.Fatalf("Close() = %v, want %v", err, wantErr) + } + process.mu.Lock() + open := process.open + process.mu.Unlock() + if open { + t.Fatal("a failing backend Close must still release the process slot") + } +} + +func TestOpenRefusesSecondCipherWhileOneIsOpen(t *testing.T) { + // The vendor C library keeps global state, so a second concurrent + // cipher must be refused rather than silently corrupt it. + process.mu.Lock() + process.open = true + process.mu.Unlock() + t.Cleanup(func() { + process.mu.Lock() + process.open = false + process.mu.Unlock() + }) + + if !Available() { + t.Skip("Open reports ErrUnavailable before reaching the single-instance guard") + } + _, err := Open(context.Background(), Config{ + KeystoreDir: filepath.Join(t.TempDir(), "keystore"), + AuthCode: StaticAuthCode("code"), + KeyServer: "https://key.example.test", + }) + if !errors.Is(err, ErrAlreadyOpen) { + t.Fatalf("Open() = %v, want ErrAlreadyOpen", err) + } +} diff --git a/internal/msgcrypto/perm_posix.go b/internal/msgcrypto/perm_posix.go new file mode 100644 index 00000000..3a57a11f --- /dev/null +++ b/internal/msgcrypto/perm_posix.go @@ -0,0 +1,20 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows + +package msgcrypto + +// runtimeSupportsPOSIXPerm reports that the keystore directory's permission +// bits are meaningful here, so prepareKeystore enforces owner-only access. +const runtimeSupportsPOSIXPerm = true diff --git a/internal/msgcrypto/perm_windows.go b/internal/msgcrypto/perm_windows.go new file mode 100644 index 00000000..dc6fa8d4 --- /dev/null +++ b/internal/msgcrypto/perm_windows.go @@ -0,0 +1,21 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package msgcrypto + +// runtimeSupportsPOSIXPerm reports that Windows does not model POSIX +// permission bits, so prepareKeystore leaves the directory mode alone and +// relies on the user profile ACL instead. +const runtimeSupportsPOSIXPerm = false diff --git a/internal/msgcrypto/portal_authcode.go b/internal/msgcrypto/portal_authcode.go new file mode 100644 index 00000000..080bc888 --- /dev/null +++ b/internal/msgcrypto/portal_authcode.go @@ -0,0 +1,170 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package msgcrypto + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" +) + +// VendorSafeChat is the first vendorAuthCode vendor. +const VendorSafeChat = "safechat" + +// PortalAuthCode mints a one-shot 免登 authCode from portal +// POST /oauth2/vendorAuthCode. It does not cache the code: goProxy spends it +// immediately. Do not wrap this in CachedAuthCode. +type PortalAuthCode struct { + ConfigDir string + Vendor string + CLIVersion string + HTTPClient *http.Client + + clientID func() string + snapshot func(ctx context.Context, configDir string) (*auth.TokenData, error) + refresh func(ctx context.Context, configDir, rejected string) (string, error) + fetch func(ctx context.Context, in auth.VendorAuthCodeInput) (*auth.VendorAuthCodeResult, error) +} + +// NewPortalAuthCode returns a provider that talks to portal with the current +// login. cliVersion is sent as x-dws-cli-version; leave empty only when the +// caller cannot know the CLI version. +func NewPortalAuthCode(configDir, cliVersion string) *PortalAuthCode { + return &PortalAuthCode{ + ConfigDir: configDir, + Vendor: VendorSafeChat, + CLIVersion: cliVersion, + clientID: auth.ClientID, + snapshot: func(ctx context.Context, dir string) (*auth.TokenData, error) { + return auth.NewOAuthProvider(dir, nil).GetTokenSnapshot(ctx) + }, + refresh: func(ctx context.Context, dir, rejected string) (string, error) { + return auth.NewOAuthProvider(dir, nil).ForceRefreshRejectedToken(ctx, rejected) + }, + fetch: auth.FetchVendorAuthCode, + } +} + +// AuthCode mints a code for the logged-in organization. Prefer AuthCodeForCorp +// when goProxy already has a corpID. +func (p *PortalAuthCode) AuthCode(ctx context.Context) (string, error) { + snap, err := p.loadSnapshot(ctx) + if err != nil { + return "", err + } + return p.AuthCodeForCorp(ctx, snap.CorpID) +} + +// AuthCodeForCorp mints a one-shot code for corpID. The request body is only +// vendor + corpId. +func (p *PortalAuthCode) AuthCodeForCorp(ctx context.Context, corpID string) (string, error) { + corpID = strings.TrimSpace(corpID) + if corpID == "" { + return "", ErrNoCorpID + } + snap, err := p.loadSnapshot(ctx) + if err != nil { + return "", err + } + token := strings.TrimSpace(snap.AccessToken) + if token == "" { + return "", fmt.Errorf("msgcrypto: access token is empty") + } + + result, err := p.fetchOnce(ctx, snap, token, corpID) + if err == nil { + return result.AuthCode, nil + } + + var verr *auth.VendorAuthCodeError + if errors.As(err, &verr) && verr.Code == auth.VendorAuthCodeTokenInvalid { + refreshed, rerr := p.doRefresh(ctx, token) + if rerr == nil && refreshed != "" && refreshed != token { + result, err = p.fetchOnce(ctx, snap, refreshed, corpID) + if err == nil { + return result.AuthCode, nil + } + } + return "", err + } + if retryableVendorAuthCode(err) { + result, err = p.fetchOnce(ctx, snap, token, corpID) + if err == nil { + return result.AuthCode, nil + } + } + return "", err +} + +func (p *PortalAuthCode) loadSnapshot(ctx context.Context) (*auth.TokenData, error) { + if p.snapshot == nil { + return nil, fmt.Errorf("msgcrypto: portal authCode snapshot loader is not configured") + } + snap, err := p.snapshot(ctx, p.ConfigDir) + if err != nil { + return nil, fmt.Errorf("msgcrypto: load access token: %w", err) + } + if snap == nil { + return nil, fmt.Errorf("msgcrypto: load access token: empty snapshot") + } + return snap, nil +} + +func (p *PortalAuthCode) fetchOnce(ctx context.Context, snap *auth.TokenData, token, corpID string) (*auth.VendorAuthCodeResult, error) { + fetch := p.fetch + if fetch == nil { + fetch = auth.FetchVendorAuthCode + } + vendor := strings.TrimSpace(p.Vendor) + if vendor == "" { + vendor = VendorSafeChat + } + clientID := strings.TrimSpace(snap.ClientID) + if clientID == "" && p.clientID != nil { + clientID = strings.TrimSpace(p.clientID()) + } + return fetch(ctx, auth.VendorAuthCodeInput{ + AccessToken: token, + ClientID: clientID, + CLIVersion: p.CLIVersion, + LoginRegion: auth.LoginRegion(strings.TrimSpace(snap.LoginRegion)), + Vendor: vendor, + CorpID: corpID, + HTTPClient: p.HTTPClient, + }) +} + +func (p *PortalAuthCode) doRefresh(ctx context.Context, rejected string) (string, error) { + if p.refresh == nil { + return "", fmt.Errorf("msgcrypto: token refresh is not configured") + } + return p.refresh(ctx, p.ConfigDir, rejected) +} + +func retryableVendorAuthCode(err error) bool { + var verr *auth.VendorAuthCodeError + if errors.As(err, &verr) { + return verr.Retryable() + } + var statusErr *auth.HTTPStatusError + if errors.As(err, &statusErr) && statusErr != nil { + return statusErr.StatusCode == http.StatusTooManyRequests || + statusErr.StatusCode >= http.StatusInternalServerError + } + return false +} diff --git a/internal/msgcrypto/portal_authcode_test.go b/internal/msgcrypto/portal_authcode_test.go new file mode 100644 index 00000000..a658a83f --- /dev/null +++ b/internal/msgcrypto/portal_authcode_test.go @@ -0,0 +1,121 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package msgcrypto + +import ( + "context" + "errors" + "sync/atomic" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" +) + +func TestPortalAuthCodePostsVendorAndCorpOnly(t *testing.T) { + var got auth.VendorAuthCodeInput + p := &PortalAuthCode{ + ConfigDir: t.TempDir(), + Vendor: VendorSafeChat, + CLIVersion: "1.2.3", + snapshot: func(context.Context, string) (*auth.TokenData, error) { + return &auth.TokenData{ + AccessToken: "user-token", + ClientID: "dws-client", + CorpID: "ding_login", + LoginRegion: "", + }, nil + }, + fetch: func(_ context.Context, in auth.VendorAuthCodeInput) (*auth.VendorAuthCodeResult, error) { + got = in + return &auth.VendorAuthCodeResult{AuthCode: "tmp-code", ExpiresIn: 120}, nil + }, + } + + code, err := p.AuthCodeForCorp(context.Background(), "ding_target") + if err != nil || code != "tmp-code" { + t.Fatalf("AuthCodeForCorp() = %q, %v", code, err) + } + if got.Vendor != VendorSafeChat || got.CorpID != "ding_target" { + t.Fatalf("body vendor/corpId = %q/%q", got.Vendor, got.CorpID) + } + if got.AccessToken != "user-token" || got.ClientID != "dws-client" || got.CLIVersion != "1.2.3" { + t.Fatalf("headers token/client/version = %q/%q/%q", got.AccessToken, got.ClientID, got.CLIVersion) + } +} + +func TestPortalAuthCodeRetriesOnceOnTokenInvalidAfterRefresh(t *testing.T) { + var calls atomic.Int32 + p := &PortalAuthCode{ + snapshot: func(context.Context, string) (*auth.TokenData, error) { + return &auth.TokenData{AccessToken: "stale", ClientID: "dws-client", CorpID: "ding"}, nil + }, + refresh: func(context.Context, string, string) (string, error) { + return "fresh", nil + }, + fetch: func(_ context.Context, in auth.VendorAuthCodeInput) (*auth.VendorAuthCodeResult, error) { + n := calls.Add(1) + if in.AccessToken == "stale" { + return nil, &auth.VendorAuthCodeError{Code: auth.VendorAuthCodeTokenInvalid, Message: "expired"} + } + if in.AccessToken != "fresh" { + t.Fatalf("retry token = %q, want fresh", in.AccessToken) + } + if n != 2 { + t.Fatalf("fetch calls = %d, want 2", n) + } + return &auth.VendorAuthCodeResult{AuthCode: "new-code", ExpiresIn: 120}, nil + }, + } + + code, err := p.AuthCodeForCorp(context.Background(), "ding") + if err != nil || code != "new-code" { + t.Fatalf("AuthCodeForCorp() = %q, %v", code, err) + } + if calls.Load() != 2 { + t.Fatalf("fetch called %d times, want 2", calls.Load()) + } +} + +func TestPortalAuthCodeDoesNotRetryOrgMismatch(t *testing.T) { + var calls atomic.Int32 + want := &auth.VendorAuthCodeError{Code: auth.VendorAuthCodeOrgMismatch, Message: "mismatch"} + p := &PortalAuthCode{ + snapshot: func(context.Context, string) (*auth.TokenData, error) { + return &auth.TokenData{AccessToken: "tok", ClientID: "dws-client", CorpID: "ding"}, nil + }, + fetch: func(context.Context, auth.VendorAuthCodeInput) (*auth.VendorAuthCodeResult, error) { + calls.Add(1) + return nil, want + }, + } + + _, err := p.AuthCodeForCorp(context.Background(), "other") + if !errors.Is(err, want) { + t.Fatalf("AuthCodeForCorp() = %v, want %v", err, want) + } + if calls.Load() != 1 { + t.Fatalf("fetch called %d times, want 1", calls.Load()) + } +} + +func TestPortalAuthCodeRequiresCorpID(t *testing.T) { + p := &PortalAuthCode{ + snapshot: func(context.Context, string) (*auth.TokenData, error) { + return &auth.TokenData{AccessToken: "tok", ClientID: "id"}, nil + }, + } + if _, err := p.AuthCodeForCorp(context.Background(), " "); !errors.Is(err, ErrNoCorpID) { + t.Fatalf("AuthCodeForCorp() = %v, want ErrNoCorpID", err) + } +} diff --git a/third_party/safechat-go-sdk/callback.go b/third_party/safechat-go-sdk/callback.go new file mode 100644 index 00000000..3b0ed2e5 --- /dev/null +++ b/third_party/safechat-go-sdk/callback.go @@ -0,0 +1,148 @@ +package safechat + +/* +#include "csrc/safechat.h" +#include "goproxy_bridge.h" +#include +*/ +import "C" +import ( + "sync/atomic" + "unsafe" +) + +var globalClient atomic.Value // stores *Client + +// registerGlobalClient stores the client reference for CGO callbacks. +func registerGlobalClient(c *Client) { + globalClient.Store(c) +} + +// getGlobalClient retrieves the active client from the atomic value. +// Returns nil if no client is registered. +func getGlobalClient() *Client { + v := globalClient.Load() + if v == nil { + return nil + } + return v.(*Client) +} + +// goProxy is the CGO callback invoked by the C library when a key request needs to be sent. +// +//export goProxy +func goProxy(corpid, uid, domain, url, param, seqID *C.char) C.int { + client := getGlobalClient() + if client == nil { + return -1 + } + + // Copy C strings to Go values immediately to avoid dangling pointer issues. + // The C layer may free these buffers after goProxy returns. + goCorpID := C.GoString(corpid) + goDomain := C.GoString(domain) + goURL := C.GoString(url) + goParam := C.GoString(param) + + if client.cfg.Logger != nil { + client.cfg.Logger.Debug("goProxy called: corpID=%s, url=%s", goCorpID, goURL) + client.cfg.Logger.Debug("goProxy input param (length=%d): %s", len(goParam), previewString(goParam, 1024)) + } + + code := client.cfg.Code + if client.cfg.AuthCodeHook != nil { + hookCode, hookErr := client.cfg.AuthCodeHook(goCorpID, goDomain) + if hookErr != nil { + if client.cfg.Logger != nil { + client.cfg.Logger.Error("goProxy: authCode hook failed for corp %s: %v", goCorpID, hookErr) + } + return -1 + } + if hookCode == "" { + if client.cfg.Logger != nil { + client.cfg.Logger.Error("goProxy: authCode hook returned an empty code for corp %s", goCorpID) + } + return -1 + } + code = hookCode + } + + // Use kcMu to protect the HTTP request - NOT c.mu! + // c.mu is already held by the caller (EncryptMsg/DecryptMsg etc.) + client.kcMu.Lock() + resp, err := client.kc.doKeyRequest(goURL, goParam, code) + client.kcMu.Unlock() + + if err != nil { + if client.cfg.Logger != nil { + client.cfg.Logger.Error("goProxy: key request failed for corp %s: %v", goCorpID, err) + } + return -1 + } + + if client.cfg.Logger != nil { + client.cfg.Logger.Debug("goProxy: HTTP request succeeded, feeding response to C setResponse (corpID=%s, response length=%d): %s", + goCorpID, len(resp), previewString(resp, 1024)) + } + + cCorpID := C.CString(goCorpID) + cResp := C.CString(resp) + defer C.free(unsafe.Pointer(cCorpID)) + defer C.free(unsafe.Pointer(cResp)) + + ret := C.setResponse( + cCorpID, + cResp, + (C.block_crypto_func)(C.goBlockBridge), + ) + + if client.cfg.Logger != nil { + client.cfg.Logger.Debug("goProxy: C.setResponse returned %d for corpID=%s", ret, goCorpID) + } + + if ret != 0 { + if client.cfg.Logger != nil { + client.cfg.Logger.Error("goProxy: C.setResponse FAILED for corpID=%s, ret=%d (key file may NOT have been generated)", goCorpID, ret) + } + return -1 + } + return 0 +} + +// goBlock is called by C library when an enterprise key becomes restricted. +// This sets a flag that can be checked by the Go application layer. +// +//export goBlock +func goBlock(corpid *C.char) C.int { + client := getGlobalClient() + if client == nil { + return -1 + } + + goCorpID := C.GoString(corpid) + if client.cfg.Logger != nil { + client.cfg.Logger.Info("goBlock: enterprise %s key restricted", goCorpID) + } + + // Store blocked status - application can check via IsBlocked() + client.blockedCorps.Store(goCorpID, true) + return 0 +} + +// goCancelBlock is called when an enterprise key restriction is lifted. +// +//export goCancelBlock +func goCancelBlock(corpid *C.char) C.int { + client := getGlobalClient() + if client == nil { + return -1 + } + + goCorpID := C.GoString(corpid) + if client.cfg.Logger != nil { + client.cfg.Logger.Info("goCancelBlock: enterprise %s key unblocked", goCorpID) + } + + client.blockedCorps.Delete(goCorpID) + return 0 +} diff --git a/third_party/safechat-go-sdk/cgo_darwin_amd64.go b/third_party/safechat-go-sdk/cgo_darwin_amd64.go new file mode 100644 index 00000000..762cee56 --- /dev/null +++ b/third_party/safechat-go-sdk/cgo_darwin_amd64.go @@ -0,0 +1,9 @@ +//go:build darwin && amd64 + +package safechat + +/* +#cgo CFLAGS: -I${SRCDIR}/csrc -I${SRCDIR}/csrc/include -DDLL_EXPORT +#cgo LDFLAGS: -L${SRCDIR}/lib/darwin_amd64 -lsafechat -lpthread -ldl -lm -framework Security +*/ +import "C" diff --git a/third_party/safechat-go-sdk/cgo_darwin_arm64.go b/third_party/safechat-go-sdk/cgo_darwin_arm64.go new file mode 100644 index 00000000..d2d58b1a --- /dev/null +++ b/third_party/safechat-go-sdk/cgo_darwin_arm64.go @@ -0,0 +1,9 @@ +//go:build darwin && arm64 + +package safechat + +/* +#cgo CFLAGS: -I${SRCDIR}/csrc -I${SRCDIR}/csrc/include -DDLL_EXPORT +#cgo LDFLAGS: -L${SRCDIR}/lib/darwin_arm64 -lsafechat -lpthread -ldl -lm -framework Security +*/ +import "C" diff --git a/third_party/safechat-go-sdk/cgo_linux_amd64.go b/third_party/safechat-go-sdk/cgo_linux_amd64.go new file mode 100644 index 00000000..f2475e04 --- /dev/null +++ b/third_party/safechat-go-sdk/cgo_linux_amd64.go @@ -0,0 +1,9 @@ +//go:build linux && amd64 + +package safechat + +/* +#cgo CFLAGS: -I${SRCDIR}/csrc -I${SRCDIR}/csrc/include -DDLL_EXPORT -D_GNU_SOURCE +#cgo LDFLAGS: -L${SRCDIR}/lib/linux_amd64 -lsafechat -lpthread -ldl -lm +*/ +import "C" diff --git a/third_party/safechat-go-sdk/cgo_linux_arm64.go b/third_party/safechat-go-sdk/cgo_linux_arm64.go new file mode 100644 index 00000000..e2d86e48 --- /dev/null +++ b/third_party/safechat-go-sdk/cgo_linux_arm64.go @@ -0,0 +1,9 @@ +//go:build linux && arm64 + +package safechat + +/* +#cgo CFLAGS: -I${SRCDIR}/csrc -I${SRCDIR}/csrc/include -DDLL_EXPORT -D_GNU_SOURCE +#cgo LDFLAGS: -L${SRCDIR}/lib/linux_arm64 -lsafechat -lpthread -ldl -lm +*/ +import "C" diff --git a/third_party/safechat-go-sdk/cgo_windows_amd64.go b/third_party/safechat-go-sdk/cgo_windows_amd64.go new file mode 100644 index 00000000..cbda34cd --- /dev/null +++ b/third_party/safechat-go-sdk/cgo_windows_amd64.go @@ -0,0 +1,9 @@ +//go:build windows && amd64 + +package safechat + +/* +#cgo CFLAGS: -I${SRCDIR}/csrc -I${SRCDIR}/csrc/include -DDLL_EXPORT -D_WIN32 -DWIN32 +#cgo LDFLAGS: ${SRCDIR}/lib/windows_amd64/libsafechat.a -lws2_32 -lgdi32 -lcrypt32 -ladvapi32 -luser32 +*/ +import "C" diff --git a/third_party/safechat-go-sdk/cgo_windows_arm64.go b/third_party/safechat-go-sdk/cgo_windows_arm64.go new file mode 100644 index 00000000..80d9c5bd --- /dev/null +++ b/third_party/safechat-go-sdk/cgo_windows_arm64.go @@ -0,0 +1,9 @@ +//go:build windows && arm64 + +package safechat + +/* +#cgo CFLAGS: -I${SRCDIR}/csrc -I${SRCDIR}/csrc/include -DDLL_EXPORT -D_WIN32 -DWIN32 +#cgo LDFLAGS: -L${SRCDIR}/lib/windows_arm64 -lsafechat -lws2_32 -lgdi32 -lcrypt32 -ladvapi32 -luser32 +*/ +import "C" diff --git a/third_party/safechat-go-sdk/config.go b/third_party/safechat-go-sdk/config.go new file mode 100644 index 00000000..d98ed97c --- /dev/null +++ b/third_party/safechat-go-sdk/config.go @@ -0,0 +1,88 @@ +package safechat + +import ( + "time" + + "github.com/google/uuid" +) + +// Logger defines the logging interface for the SDK. +// Implementations should be safe for concurrent use. +type Logger interface { + // Debug logs a debug-level message + Debug(msg string, args ...interface{}) + // Info logs an info-level message + Info(msg string, args ...interface{}) + // Error logs an error-level message + Error(msg string, args ...interface{}) +} + +// Config holds configuration parameters for the SafeChat client. +type Config struct { + // DataPath is the directory path for storing keys and related data (required). + // The directory must exist and be writable. + DataPath string + + // UserID is an optional user identifier. If empty, a random UUID is + // generated automatically. The C library stores this value but does not + // use it for key operations, so any non-empty string works. + UserID string + + // Code is a DingTalk 免登 authCode used for key server authentication + // when AuthCodeHook is nil. Prefer AuthCodeHook: the code is one-shot + // and should be minted only when goProxy actually needs a key. + // + // Required (via Code or AuthCodeHook) when: + // - First-time key fetch (empty keystore) + // - Server-side key version rotation + // + // If keys are already cached AND the key version has not changed, + // neither field is used (no network request is made). + Code string + + // AuthCodeHook is called from goProxy immediately before the key + // request. corpID and domain come from the C library callback. + // domain is the vendor SSO host and must not be forwarded as an + // authorize redirectURI; it is only for local host checks. + // If set, it replaces Code for that request and the returned value + // is not stored on the client. + AuthCodeHook func(corpID, domain string) (string, error) + + // KeyServer is an optional override for the key server URL. + // If empty, the URL provided by the C library's goProxy callback will be used. + KeyServer string + + // MaxRetry is the maximum number of retry attempts when key is not yet available. + // Default: 5 (private server redirect consumes 2 retries, so 5 provides sufficient margin) + MaxRetry int + + // HTTPTimeout is the timeout for HTTP key requests. + // Default: 10s + HTTPTimeout time.Duration + + // Logger is an optional logger instance. + // If nil, no logging will be performed. + Logger Logger +} + +// defaultConfig returns a Config with sensible defaults applied. +func defaultConfig(cfg Config) Config { + if cfg.MaxRetry <= 0 { + cfg.MaxRetry = 5 + } + if cfg.HTTPTimeout <= 0 { + cfg.HTTPTimeout = 10 * time.Second + } + if cfg.UserID == "" { + cfg.UserID = uuid.New().String() + } + return cfg +} + +// validate checks that required config fields are set. +func (cfg *Config) validate() error { + if cfg.DataPath == "" { + return ErrConfigDataPathEmpty + } + return nil +} diff --git a/third_party/safechat-go-sdk/csrc/safechat.h b/third_party/safechat-go-sdk/csrc/safechat.h new file mode 100644 index 00000000..2b8b21af --- /dev/null +++ b/third_party/safechat-go-sdk/csrc/safechat.h @@ -0,0 +1,137 @@ +#ifndef SAFECHAT_H +#define SAFECHAT_H +#ifdef WIN32 +#ifndef DLL_EXPORT +#define CREATEDLL_API __declspec(dllimport) +#else +#define CREATEDLL_API __declspec(dllexport) +#endif +#else +#define CREATEDLL_API __attribute__((visibility("default"))) +#endif + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef unsigned char byte; + +// using namespace std; +// #pragma comment(lib, "safechat.lib") + +#define DTMsgRetOK 0 /*operate return success*/ + + +#define WARNING_RET_CODE 0x10000000 +enum { RET_CODE_SUCCESS = 0, RET_CODE_ERROR, RET_CODE_WARNING }; + +// 获取返回值的类型,RET_CODE_WARNING | RET_CODE_ERROR| RET_CODE_SUCCESS +// #define GET_RET_CODETYPE(x) (x == 0) ? RET_CODE_SUCCESS : ((-x) & WARNING_RET_CODE == WARNING_RET_CODE ? +// RET_CODE_WARNING : RET_CODE_ERROR) + +/* +打印日志回调函数 +@param:ret_type 打印日志类型,RET_CODE_WARNING | RET_CODE_ERROR| RET_CODE_SUCCESS +@param:msg_detail 打印日志的详细信息,char * UTF-8格式 +@return 备用,暂且返回NULL +*/ +typedef void *(*log_func)(int ret_type, char *msg_detail); + +/* +代理回调函数 +@param: corpid 企业id +@param: uid 当前用户id +@param: domain 请求的域名 +@param: url 请求url +@param: param 请求参数 +@return: 返回执行结果 +@param: seq_id 回调函数线程识别参数 +*/ +typedef int (*call_proxy_func)(char *corpid, char *uid, char *domain, char *url, char *param, + char *seq_id); + +/* +设置密钥管控标记 +@param: corpid 企业id +*/ +typedef int (*block_crypto_func)(char *corpid); + +/* +解除密钥管控标记 +@param: corpid 企业id +*/ +typedef int (*cancel_block_crypto_func)(char *corpid); +/* +初始化函数 +@param: path 用户数据路径, 保存key等信息 +@param: my_id 当前登W录用户的id +@return: 返回执行结果 +*/ +CREATEDLL_API int safechatInit(char *path, char *my_id); +/* +数据加密函数 +@param: corp_id 企业id +@param: staffid 员工id +@param: data_buf 加密前数据 +@param: data_len data_buf长度 +@param: id 对方用户id, 如果群消息设置为NULL +@param: encrypt_buf 加密后的数据指针,需要外部函数释放 +@param: ret_len 加密数据的返回长度 +@param: proxy 代理请求回调函数 +@return: 返回执行结果 +@param: seq_id 回调函数线程识别参数 +*/ +CREATEDLL_API int encryptData(char *corp_id, char *staffid, unsigned char *data_buf, unsigned int data_len, char *id, + unsigned char **encrypt_buf, unsigned int *ret_len, char *seq_id, call_proxy_func proxy); +/* +数据解密函数 +@param: corp_id 企业id +@param: staffid 员工id +@param: data_buf 解密前数据 +@param: data_len data_buf长度 +@param: id 对方用户id, 如果群消息设置为NULL +@param: decrypt_buf 解密后的数据指针,需要外部函数释放 +@param: ret_len 解密数据的返回长度 +@param: proxy 代理请求回调函数 +@return: 返回执行结果 +@param: seq_id 回调函数线程识别参数 +*/ +CREATEDLL_API int decryptData(char *corp_id, char *staffid, unsigned char *data_buf, unsigned int data_len, char *id, + unsigned char **decrypt_buf, unsigned int *ret_len, char *seq_id, call_proxy_func proxy); + +CREATEDLL_API int encryptFile(char *corp_id, char *staffid, char *id, char *in_file_path, char *out_file_path, + char *seq_id, call_proxy_func proxy); +CREATEDLL_API int encryptBuffer(char *corp_id, char *staffid, byte *data_buf, uint32_t data_len, char *id, + byte **encrypt_buf, uint32_t *ret_len, char *seq_id, call_proxy_func proxy); +CREATEDLL_API int decryptFile(char *corp_id, char *staffid, char *id, char *in_file_path, char *out_file_path, + char *seq_id, call_proxy_func proxy); +CREATEDLL_API int decryptBuffer(char *corp_id, char *staffid, byte *data_buf, uint32_t data_len, char *id, + byte **decrypt_buf, uint32_t *ret_len, char *seq_id, call_proxy_func proxy); + +/* +服务器响应处理 +@param: corp_id 企业id +@param: json_str json格式的服务器返回值 +@return: 返回执行结果 +*/ +CREATEDLL_API int setResponse(char *corp_id, char *json_str, block_crypto_func block_func1); + +/* +消息推送处理 +@param: corp_id 企业id +@param: staffid 员工id +@param: push_data 发送请求的数据 +@param: proxy 代理请求回调函数 +@return: 返回执行结果 +@param: seq_id 回调函数线程识别参数 +*/ +CREATEDLL_API int setPushData(char *corp_id, char *staffid, char *push_data, char *seq_id, call_proxy_func proxy, + cancel_block_crypto_func cancel_crypto_func); + +#ifdef __cplusplus +} +#endif + +#endif // SAFECHAT_H \ No newline at end of file diff --git a/third_party/safechat-go-sdk/errors.go b/third_party/safechat-go-sdk/errors.go new file mode 100644 index 00000000..0e0aaefb --- /dev/null +++ b/third_party/safechat-go-sdk/errors.go @@ -0,0 +1,265 @@ +package safechat + +import ( + "errors" + "fmt" +) + +// SDK-level errors (Go side) +var ( + // ErrConfigDataPathEmpty indicates DataPath is not set in Config. + ErrConfigDataPathEmpty = errors.New("safechat: config.DataPath is required") + + // ErrConfigUserIDEmpty indicates UserID is not set in Config. + ErrConfigUserIDEmpty = errors.New("safechat: config.UserID is required") + + // ErrNotInitialized indicates the client has not been initialized. + ErrNotInitialized = errors.New("safechat: client not initialized") + + // ErrMaxRetryExceeded indicates max retry attempts for key acquisition exceeded. + ErrMaxRetryExceeded = errors.New("safechat: max retry exceeded, key not available") + + // ErrKeyRestricted indicates the enterprise key is restricted (managed/blocked). + ErrKeyRestricted = errors.New("safechat: enterprise key is restricted by admin") + + // ErrKeyRequestFailed indicates the HTTP key request failed. + ErrKeyRequestFailed = errors.New("safechat: key request HTTP call failed") + + // ErrAlreadyInitialized indicates init was called more than once. + ErrAlreadyInitialized = errors.New("safechat: client already initialized") +) + +// CError represents an error code returned from the C library layer. +type CError struct { + Code int + Message string +} + +func (e *CError) Error() string { + return fmt.Sprintf("safechat: C library error %d: %s", e.Code, e.Message) +} + +// mapCError maps a C library return code to a Go error. +// Returns nil if code == 0 (FUNCTION_OK). +func mapCError(code int) error { + if code == 0 { + return nil + } + + msg, ok := cErrorMessages[code] + if !ok { + msg = "unknown error" + } + + return &CError{Code: code, Message: msg} +} + +// C library error code constants - mapped from native.h +const ( + // Special status codes + cFunctionOK = 0 + cSendRequestParamOK = -15002 // Key not found, request sent via goProxy + + // Init errors (-20000 ~ -20016) + cInitParamPathNull = -20000 + cInitParamMyIDNull = -20001 + cInitPathUTF8Null = -20002 + cInitPathNotAvailable = -20003 + cInitLogInitError = -20004 + cInitEncryptInitError = -20005 + cInitPCNativeError = -20006 + cInitReadLocalKey = -20013 + cInitReadKey = -20014 + + // EncryptData errors (-20100 ~ -20114) + cEncryptDataCorpIDNull = -20100 + cEncryptDataMsgNull = -20101 + cEncryptDataBufNull = -20102 + cEncryptDataRetLenNull = -20103 + cEncryptDataProxyNull = -20104 + cEncryptDataLenError = -20105 + cEncryptDataKeyNegtive = -20111 + cEncryptDataTmpKeyNull = -20109 + cEncryptDataBuildReqErr = -20110 + + // DecryptData errors (-20200 ~ -20215) + cDecryptDataCorpIDNull = -20200 + cDecryptDataMsgNull = -20201 + cDecryptDataBufNull = -20202 + cDecryptDataRetLenNull = -20203 + cDecryptDataProxyNull = -20204 + cDecryptDataLenError = -20205 + cDecryptDataFormatError = -20206 + cDecryptDataDecryptErr = -20211 + cDecryptDataKeyNegtive = -20214 + cDecryptDataTmpKeyNull = -20212 + cDecryptDataBuildReqErr = -20213 + + // EncryptFile errors (-20300 ~ -20339) + cEncryptFileCorpIDNull = -20300 + cEncryptFilePathNull = -20301 + cEncryptFileTmpKeyNull = -20315 + cEncryptFileBuildReqErr = -20316 + + // EncryptBuffer errors (-20400 ~ -20416) + cEncryptBufferCorpIDNull = -20400 + cEncryptBufferTmpKeyNull = -20414 + cEncryptBufferBuildReqErr = -20415 + + // DecryptFile errors (-20500 ~ -20538) + cDecryptFileCorpIDNull = -20500 + cDecryptFilePathNull = -20501 + cDecryptFileHeadError = -20505 + cDecryptFileHashError = -20517 + cDecryptFileTmpKeyNull = -20518 + cDecryptFileBuildReqErr = -20519 + + // DecryptBuffer errors (-20600 ~ -20622) + cDecryptBufferCorpIDNull = -20600 + cDecryptBufferHeadNull = -20608 + cDecryptBufferHashError = -20618 + cDecryptBufferTmpKeyNull = -20619 + cDecryptBufferBuildReqErr = -20620 + + // SetResponse errors (-20700 ~ -20730) + cSetResponseCorpIDNull = -20700 + cSetResponseJSONNull = -20701 + cSetResponseKeyNagtive = -20728 + cSetResponseSaveKeyErr = -20723 + + // SetPushData errors (-20800 ~ -20814) + cSetPushDataCorpIDNull = -20800 + cSetPushDataTypeUndef = -20814 + + // V3 signing errors (-31001 ~ -34002) + cV3BuildReq3SM2Failed = -31006 + cV3BuildReq3SignError = -31009 + cV3ParseResp3Downgrade = -32012 +) + +// cErrorMessages maps C error codes to human-readable messages. +var cErrorMessages = map[int]string{ + // Init + -20000: "init: path parameter is NULL", + -20001: "init: my_id parameter is NULL", + -20002: "init: path UTF8 conversion returned NULL", + -20003: "init: path is not available/accessible", + -20004: "init: log initialization failed", + -20005: "init: encryption engine initialization failed", + -20006: "init: pcnative initialization failed", + -20007: "init: get URL full path error", + -20013: "init: read local key failed", + -20014: "init: read key failed", + -20015: "init: my_id parameter is NULL", + -20016: "init: logFunc parameter is NULL", + + // EncryptData + -20100: "encryptData: corp_id is NULL or empty", + -20101: "encryptData: message content is NULL", + -20102: "encryptData: encrypt_buf is NULL", + -20103: "encryptData: ret_len is NULL", + -20104: "encryptData: proxy function is NULL", + -20105: "encryptData: data length invalid (too small or too big)", + -20106: "encryptData: encryptMsgHelper parameter error", + -20107: "encryptData: SM4 encryption failed", + -20108: "encryptData: base64 encoding failed", + -20109: "encryptData: key request already in progress", + -20110: "encryptData: build key request failed", + -20111: "encryptData: enterprise key is restricted", + -20113: "encryptData: malloc encode buffer failed", + -20114: "encryptData: malloc encrypt buffer failed", + + // DecryptData + -20200: "decryptData: corp_id is NULL or empty", + -20201: "decryptData: message content is NULL", + -20202: "decryptData: decrypt_buf is NULL", + -20203: "decryptData: ret_len is NULL", + -20204: "decryptData: proxy function is NULL", + -20205: "decryptData: data length invalid", + -20206: "decryptData: message content format error (missing ||separators)", + -20207: "decryptData: decryptMsgHelper parameter error", + -20208: "decryptData: base64 decode failed", + -20209: "decryptData: decode length error", + -20210: "decryptData: decrypt buffer malloc failed", + -20211: "decryptData: SM4 decryption failed", + -20212: "decryptData: key request already in progress", + -20213: "decryptData: build key request failed", + -20214: "decryptData: enterprise key is restricted", + + // EncryptFile + -20300: "encryptFile: corp_id is NULL or empty", + -20301: "encryptFile: source or dest file path is NULL", + -20302: "encryptFile: id parameter is NULL", + -20303: "encryptFile: seq_id parameter is NULL", + -20304: "encryptFile: proxy function is NULL", + -20305: "encryptFile: key_info is NULL", + -20308: "encryptFile: get file size failed", + -20309: "encryptFile: open source or dest file failed", + -20313: "encryptFile: encrypt block failed", + -20315: "encryptFile: key request already in progress", + -20316: "encryptFile: build key request failed", + -20333: "encryptFile: create thread failed", + -20339: "encryptFile: test environment error", + + // EncryptBuffer + -20400: "encryptBuffer: corp_id is NULL or empty", + -20401: "encryptBuffer: file content is NULL", + -20407: "encryptBuffer: proxy function is NULL", + -20413: "encryptBuffer: encrypted block error", + -20414: "encryptBuffer: key request already in progress", + -20415: "encryptBuffer: build key request failed", + + // DecryptFile + -20500: "decryptFile: corp_id is NULL or empty", + -20501: "decryptFile: source or dest file path is NULL", + -20505: "decryptFile: file header format error", + -20509: "decryptFile: header magic error", + -20511: "decryptFile: open file failed", + -20515: "decryptFile: decrypt block failed", + -20517: "decryptFile: file hash verification failed (warning)", + -20518: "decryptFile: key request already in progress", + -20519: "decryptFile: build key request failed", + -20520: "decryptFile: file length error", + + // DecryptBuffer + -20600: "decryptBuffer: corp_id is NULL or empty", + -20608: "decryptBuffer: parseHeadInfo returned NULL", + -20615: "decryptBuffer: header magic error", + -20617: "decryptBuffer: decrypt block error", + -20618: "decryptBuffer: file hash verification failed", + -20619: "decryptBuffer: key request already in progress", + -20620: "decryptBuffer: build key request failed", + -20621: "decryptBuffer: file length error", + + // SetResponse + -20700: "setResponse: corp_id is NULL or empty", + -20701: "setResponse: json_str is NULL or empty", + -20703: "setResponse: find tmp corp no tmp key found", + -20704: "setResponse: JSON parse failed", + -20716: "setResponse: server request error", + -20722: "setResponse: localKeyPath is NULL", + -20723: "setResponse: save key failed", + -20728: "setResponse: enterprise key is restricted (nagtive)", + + // SetPushData + -20800: "setPushData: corp_id is NULL", + -20801: "setPushData: push_data is NULL", + -20804: "setPushData: JSON parse failed", + -20805: "setPushData: type field is NULL", + -20806: "setPushData: find_create_tmp_key failed", + -20814: "setPushData: unknown push type", + + // V3 Signing + -31001: "v3: build_key_request3 arg is NULL", + -31006: "v3: SM2 encrypt R2 failed", + -31009: "v3: sign step one error", + -32005: "v3: deserialize response failed", + -32010: "v3: new SDK request old private error", + -32012: "v3: public server downgrade", + -32022: "v3: MAC of response mismatch", + -32029: "v3: verify server sign failed", + + // Misc + -34001: "v3: setResponse not found tmp sign", + -34002: "v3: setResponse malloc for ret buf failed", +} diff --git a/third_party/safechat-go-sdk/example/main.go b/third_party/safechat-go-sdk/example/main.go new file mode 100644 index 00000000..916ea317 --- /dev/null +++ b/third_party/safechat-go-sdk/example/main.go @@ -0,0 +1,463 @@ +// Package main demonstrates how to integrate the SafeChat Go SDK +// into the DingTalk Workspace CLI or any other Go application. +// +// Two usage modes: +// +// 1. Single-action mode (default) +// Run one of: encrypt-msg / decrypt-msg / encrypt-file / decrypt-file / +// encrypt-buf / decrypt-buf via -action flag. +// +// 2. Full-test mode (-test-all) +// Exercise all 3 encryption APIs (Msg / File / Buffer) in one run, +// verify round-trip (decrypt == original), and print a summary. +// +// Key cache files +// +// The SDK persists negotiated keys under -data directory: +// ahflag_256.store local key identifier +// ahkey_256.store encrypted key material +// +// Build: +// +// go build -o safechat-example ./example/ +package main + +import ( + "bytes" + "crypto/rand" + "flag" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" + + safechat "safechat-go-sdk" +) + +func main() { + var ( + dataPath = flag.String("data", "./keystore", "Path to key storage directory (contains ahflag_256.store / ahkey_256.store)") + userID = flag.String("user", "", "Current login user ID") + code = flag.String("code", "", "DingTalk authCode (only needed when keys must be fetched from server)") + corpID = flag.String("corp", "", "Enterprise/corp ID (required)") + staffID = flag.String("staff", "", "Staff ID for encryption target (defaults to -user)") + action = flag.String("action", "", "Single action: encrypt-msg|decrypt-msg|encrypt-file|decrypt-file|encrypt-buf|decrypt-buf") + input = flag.String("input", "", "Input text / file path") + output = flag.String("output", "", "Output file path (for file operations)") + server = flag.String("server", "", "Key server URL override (optional)") + testAll = flag.Bool("test-all", false, "Run a full round-trip test of all 3 encryption APIs using cached keys") + verbose = flag.Bool("v", false, "Verbose logging") + ) + flag.Parse() + + if *corpID == "" { + fmt.Fprintln(os.Stderr, "Error: -corp flag is required") + flag.Usage() + os.Exit(1) + } + + // Validate key cache files up-front so the user gets a clear message + // instead of a cryptic C-library error later. + checkKeyCache(*dataPath, *code) + + cfg := safechat.Config{ + DataPath: *dataPath, + UserID: *userID, + Code: *code, + MaxRetry: 5, + HTTPTimeout: 15 * time.Second, + } + if !*verbose { + cfg.Logger = &quietLogger{} + } else { + cfg.Logger = &stdLogger{} + } + if *server != "" { + cfg.KeyServer = *server + } + + client, err := safechat.New(cfg) + if err != nil { + log.Fatalf("Failed to initialize SafeChat client: %v", err) + } + defer client.Close() + + targetStaff := *staffID + if targetStaff == "" { + targetStaff = *userID + } + + if *testAll { + runFullTest(client, *corpID, targetStaff) + return + } + + if *action == "" { + fmt.Fprintln(os.Stderr, "Error: either -action or -test-all must be provided") + flag.Usage() + os.Exit(1) + } + + switch *action { + case "encrypt-msg": + encryptMessage(client, *corpID, targetStaff, *input) + case "decrypt-msg": + decryptMessage(client, *corpID, targetStaff, *input) + case "encrypt-file": + encryptFile(client, *corpID, targetStaff, *input, *output) + case "decrypt-file": + decryptFile(client, *corpID, targetStaff, *input, *output) + case "encrypt-buf": + encryptBuffer(client, *corpID, targetStaff, *input) + case "decrypt-buf": + decryptBuffer(client, *corpID, targetStaff, *input) + default: + fmt.Fprintf(os.Stderr, "Unknown action: %s\n", *action) + os.Exit(1) + } +} + +// ---------- key-cache pre-check ---------- + +// checkKeyCache prints a friendly hint about which keys are available and +// whether a network round-trip to the key server should be expected. +func checkKeyCache(dataPath, code string) { + flagFile := filepath.Join(dataPath, "ahflag_256.store") + keyFile := filepath.Join(dataPath, "ahkey_256.store") + + _, err1 := os.Stat(flagFile) + _, err2 := os.Stat(keyFile) + + hasFlag := err1 == nil + hasKey := err2 == nil + + switch { + case hasFlag && hasKey: + fmt.Printf("[key-cache] found %s + %s (existence check only; C layer still validates hash/version)\n", + filepath.Base(flagFile), filepath.Base(keyFile)) + fmt.Printf("[key-cache] NOTE: store files are NOT portable across CPU architectures — " + + "calc_hash() depends on char signedness (signed on x86_64, unsigned on AArch64). " + + "A foreign store fails the hash check, gets deleted and re-generated, which triggers a server call.\n") + case hasFlag || hasKey: + fmt.Fprintf(os.Stderr, + "[key-cache] WARNING: only one of the pair exists (%s / %s); key negotiation will likely fail\n", + filepath.Base(flagFile), filepath.Base(keyFile)) + default: + if code == "" { + fmt.Fprintf(os.Stderr, + "[key-cache] no cached keys in %s and -code is empty; "+ + "either copy ahflag_256.store + ahkey_256.store into that dir, "+ + "or provide a valid DingTalk access_token via -code\n", + dataPath) + } else { + fmt.Printf("[key-cache] no cached keys in %s; will try to fetch from server using -code\n", dataPath) + } + } +} + +// ---------- full round-trip test of all 3 APIs ---------- + +// runFullTest exercises the 3 Go API pairs defined in safechat.go. +// +// The APIs under test are the PUBLIC Go methods on *safechat.Client: +// +// API #1 — Msg API : EncryptMsg / DecryptMsg (safechat.go L115, L154) +// API #2 — File API : EncryptFile / DecryptFile (safechat.go L188, L213) +// API #3 — Buffer API : EncryptBuffer / DecryptBuffer (safechat.go L240, L268) +// +// These are pure Go entry points: parameter validation, mutex locking, +// MaxRetry loop, and Go error translation are all done in safechat.go. +// The underlying CGO bridge (cEncryptData / cDecryptData / ...) is a +// PRIVATE implementation detail and is NOT what this test targets. +func runFullTest(client *safechat.Client, corpID, staffID string) { + fmt.Println("==========================================================") + fmt.Printf(" SafeChat Go SDK — Go API round-trip test\n") + fmt.Printf(" corpID=%s staffID=%s\n", corpID, staffID) + fmt.Println(" Target: the 3 public Go API pairs in safechat.go") + fmt.Println(" #1 Msg API : EncryptMsg / DecryptMsg") + fmt.Println(" #2 File API : EncryptFile / DecryptFile") + fmt.Println(" #3 Buffer API : EncryptBuffer / DecryptBuffer") + fmt.Println("==========================================================") + + passed, failed := 0, 0 + + // Go API #1 — Msg + fmt.Println("\n[1/3] Go API #1 : EncryptMsg / DecryptMsg (safechat.go L115, L154)") + if runCase("Msg API", func() error { return testMsgAPI(client, corpID, staffID) }) { + passed++ + } else { + failed++ + } + + // Go API #2 — File + fmt.Println("\n[2/3] Go API #2 : EncryptFile / DecryptFile (safechat.go L188, L213)") + if runCase("File API", func() error { return testFileAPI(client, corpID, staffID) }) { + passed++ + } else { + failed++ + } + + // Go API #3 — Buffer + fmt.Println("\n[3/3] Go API #3 : EncryptBuffer / DecryptBuffer (safechat.go L240, L268)") + if runCase("Buffer API", func() error { return testBufferAPI(client, corpID, staffID) }) { + passed++ + } else { + failed++ + } + + fmt.Println("\n==========================================================") + fmt.Printf(" RESULT: %d passed, %d failed\n", passed, failed) + fmt.Println("==========================================================") + if failed > 0 { + os.Exit(1) + } +} + +func runCase(name string, fn func() error) bool { + if err := fn(); err != nil { + fmt.Printf(" ✗ %s FAILED: %v\n", name, err) + return false + } + fmt.Printf(" ✓ %s OK\n", name) + return true +} + +// testMsgAPI exercises Go API #1: +// +// client.EncryptMsg(corpID, staffID, plain) → ciphertext +// client.DecryptMsg(corpID, staffID, ciphertext) → plaintext +// +// Both methods are defined in safechat.go (L115, L154). They perform +// parameter validation, mutex locking, and a MaxRetry loop before +// returning a Go []byte + error. +func testMsgAPI(c *safechat.Client, corpID, staffID string) error { + plain := make([]byte, 128) + if _, err := rand.Read(plain); err != nil { + return err + } + + // Go API call — safechat.go L115 + ct, err := c.EncryptMsg(corpID, staffID, plain) + if err != nil { + return fmt.Errorf("EncryptMsg (Go API): %w", err) + } + fmt.Printf(" [Go API] EncryptMsg : plain=%d bytes -> ct=%d bytes\n", len(plain), len(ct)) + fmt.Printf(" [ciphertext str] %s\n", strings.ReplaceAll(string(ct), "\n", "")) + + // Go API call — safechat.go L154 + got, err := c.DecryptMsg(corpID, staffID, ct) + if err != nil { + return fmt.Errorf("DecryptMsg (Go API): %w", err) + } + if !bytes.Equal(got, plain) { + return fmt.Errorf("round-trip mismatch: got %d bytes, want %d", len(got), len(plain)) + } + fmt.Printf(" [Go API] DecryptMsg : ct=%d bytes -> plain=%d bytes OK\n", len(ct), len(got)) + return nil +} + +// testFileAPI exercises Go API #2: +// +// client.EncryptFile(corpID, staffID, src, enc) error +// client.DecryptFile(corpID, staffID, enc, dec) error +// +// Both methods are defined in safechat.go (L188, L213). They operate on +// file paths and return only an error — no data crosses the Go/C boundary +// in the caller-visible API. +func testFileAPI(c *safechat.Client, corpID, staffID string) error { + dir, err := os.MkdirTemp("", "safechat-file-test-*") + if err != nil { + return err + } + defer os.RemoveAll(dir) + + src := filepath.Join(dir, "plain.bin") + enc := filepath.Join(dir, "plain.bin.enc") + dec := filepath.Join(dir, "plain.bin.dec") + + buf := make([]byte, 1024) + if _, err := rand.Read(buf); err != nil { + return err + } + if err := os.WriteFile(src, buf, 0644); err != nil { + return err + } + + // Go API call — safechat.go L188 + if err := c.EncryptFile(corpID, staffID, src, enc); err != nil { + return fmt.Errorf("EncryptFile (Go API): %w", err) + } + encSize, _ := fileSize(enc) + fmt.Printf(" [Go API] EncryptFile : src=%d bytes -> enc=%d bytes\n", len(buf), encSize) + + // Go API call — safechat.go L213 + if err := c.DecryptFile(corpID, staffID, enc, dec); err != nil { + return fmt.Errorf("DecryptFile (Go API): %w", err) + } + decBuf, err := os.ReadFile(dec) + if err != nil { + return err + } + if !bytes.Equal(decBuf, buf) { + return fmt.Errorf("round-trip mismatch: dec=%d bytes, want=%d", len(decBuf), len(buf)) + } + fmt.Printf(" [Go API] DecryptFile : enc=%d bytes -> dec=%d bytes OK\n", encSize, len(decBuf)) + return nil +} + +// testBufferAPI exercises Go API #3: +// +// client.EncryptBuffer(corpID, staffID, data) → []byte, error +// client.DecryptBuffer(corpID, staffID, data) → []byte, error +// +// Both methods are defined in safechat.go (L240, L268). They operate on +// in-memory []byte buffers, suitable for binary protocols or DB blobs. +func testBufferAPI(c *safechat.Client, corpID, staffID string) error { + plain := make([]byte, 512) + if _, err := rand.Read(plain); err != nil { + return err + } + + // Go API call — safechat.go L240 + ct, err := c.EncryptBuffer(corpID, staffID, plain) + if err != nil { + return fmt.Errorf("EncryptBuffer (Go API): %w", err) + } + fmt.Printf(" [Go API] EncryptBuffer : plain=%d bytes -> ct=%d bytes\n", len(plain), len(ct)) + + // Go API call — safechat.go L268 + got, err := c.DecryptBuffer(corpID, staffID, ct) + if err != nil { + return fmt.Errorf("DecryptBuffer (Go API): %w", err) + } + if !bytes.Equal(got, plain) { + return fmt.Errorf("round-trip mismatch: got %d bytes, want %d", len(got), len(plain)) + } + fmt.Printf(" [Go API] DecryptBuffer : ct=%d bytes -> plain=%d bytes OK\n", len(ct), len(got)) + return nil +} + +func fileSize(p string) (int64, error) { + fi, err := os.Stat(p) + if err != nil { + return 0, err + } + return fi.Size(), nil +} + +// ---------- single-action helpers ---------- + +func encryptMessage(client *safechat.Client, corpID, staffID, plaintext string) { + if plaintext == "" { + plaintext = "Hello, this is a test message from SafeChat Go SDK!" + } + fmt.Printf("Encrypting message: %q\n", plaintext) + ciphertext, err := client.EncryptMsg(corpID, staffID, []byte(plaintext)) + if err != nil { + log.Fatalf("EncryptMsg failed: %v", err) + } + fmt.Printf("Encrypted (%d bytes): %s\n", len(ciphertext), string(ciphertext)) +} + +func decryptMessage(client *safechat.Client, corpID, staffID, ciphertext string) { + if ciphertext == "" { + log.Fatal("decrypt-msg requires -input with the ciphertext") + } + fmt.Printf("Decrypting message (%d bytes)...\n", len(ciphertext)) + plaintext, err := client.DecryptMsg(corpID, staffID, []byte(ciphertext)) + if err != nil { + log.Fatalf("DecryptMsg failed: %v", err) + } + fmt.Printf("Decrypted: %s\n", string(plaintext)) +} + +func encryptFile(client *safechat.Client, corpID, staffID, srcPath, dstPath string) { + if srcPath == "" { + log.Fatal("encrypt-file requires -input with source file path") + } + if dstPath == "" { + dstPath = srcPath + ".enc" + } + fmt.Printf("Encrypting file: %s -> %s\n", srcPath, dstPath) + if err := client.EncryptFile(corpID, staffID, srcPath, dstPath); err != nil { + log.Fatalf("EncryptFile failed: %v", err) + } + fmt.Println("File encrypted successfully") +} + +func decryptFile(client *safechat.Client, corpID, staffID, srcPath, dstPath string) { + if srcPath == "" { + log.Fatal("decrypt-file requires -input with source file path") + } + if dstPath == "" { + dstPath = srcPath + ".dec" + } + fmt.Printf("Decrypting file: %s -> %s\n", srcPath, dstPath) + if err := client.DecryptFile(corpID, staffID, srcPath, dstPath); err != nil { + log.Fatalf("DecryptFile failed: %v", err) + } + fmt.Println("File decrypted successfully") +} + +func encryptBuffer(client *safechat.Client, corpID, staffID, inputPath string) { + if inputPath == "" { + log.Fatal("encrypt-buf requires -input with file path containing data to encrypt") + } + data, err := os.ReadFile(inputPath) + if err != nil { + log.Fatalf("Failed to read input file: %v", err) + } + fmt.Printf("Encrypting buffer (%d bytes)...\n", len(data)) + encrypted, err := client.EncryptBuffer(corpID, staffID, data) + if err != nil { + log.Fatalf("EncryptBuffer failed: %v", err) + } + outPath := inputPath + ".enc" + if err := os.WriteFile(outPath, encrypted, 0644); err != nil { + log.Fatalf("Failed to write output: %v", err) + } + fmt.Printf("Buffer encrypted successfully (%d bytes) -> %s\n", len(encrypted), outPath) +} + +func decryptBuffer(client *safechat.Client, corpID, staffID, inputPath string) { + if inputPath == "" { + log.Fatal("decrypt-buf requires -input with file path containing data to decrypt") + } + data, err := os.ReadFile(inputPath) + if err != nil { + log.Fatalf("Failed to read input file: %v", err) + } + fmt.Printf("Decrypting buffer (%d bytes)...\n", len(data)) + decrypted, err := client.DecryptBuffer(corpID, staffID, data) + if err != nil { + log.Fatalf("DecryptBuffer failed: %v", err) + } + outPath := inputPath + ".dec" + if err := os.WriteFile(outPath, decrypted, 0644); err != nil { + log.Fatalf("Failed to write output: %v", err) + } + fmt.Printf("Buffer decrypted successfully (%d bytes) -> %s\n", len(decrypted), outPath) +} + +// ---------- loggers ---------- + +type stdLogger struct{} + +func (l *stdLogger) Debug(format string, args ...interface{}) { + log.Printf("[DEBUG] "+format, args...) +} +func (l *stdLogger) Info(format string, args ...interface{}) { + log.Printf("[INFO] "+format, args...) +} +func (l *stdLogger) Error(format string, args ...interface{}) { + log.Printf("[ERROR] "+format, args...) +} + +// quietLogger swallows SDK logs; only errors are surfaced via returned error values. +type quietLogger struct{} + +func (l *quietLogger) Debug(format string, args ...interface{}) {} +func (l *quietLogger) Info(format string, args ...interface{}) {} +func (l *quietLogger) Error(format string, args ...interface{}) {} diff --git a/third_party/safechat-go-sdk/go.mod b/third_party/safechat-go-sdk/go.mod new file mode 100644 index 00000000..33a122af --- /dev/null +++ b/third_party/safechat-go-sdk/go.mod @@ -0,0 +1,5 @@ +module safechat-go-sdk + +go 1.21 + +require github.com/google/uuid v1.6.0 diff --git a/third_party/safechat-go-sdk/go.sum b/third_party/safechat-go-sdk/go.sum new file mode 100644 index 00000000..7790d7c3 --- /dev/null +++ b/third_party/safechat-go-sdk/go.sum @@ -0,0 +1,2 @@ +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= diff --git a/third_party/safechat-go-sdk/goproxy_bridge.c b/third_party/safechat-go-sdk/goproxy_bridge.c new file mode 100644 index 00000000..df864a3c --- /dev/null +++ b/third_party/safechat-go-sdk/goproxy_bridge.c @@ -0,0 +1,80 @@ +#include "goproxy_bridge.h" +#include "csrc/safechat.h" +#include + +/* + * goproxy_bridge.c - CGO callback bridge implementation + * + * This file MUST reside in the Go package root directory so that + * CGO compiles it together with the Go code. It bridges C library + * callback invocations to Go exported functions. + * + * Go exported functions are declared as extern here. + * The bridge functions simply forward calls from C library + * to the Go runtime via CGO mechanism. + */ + +/* Go exported function declarations (defined in callback.go) */ +extern int goProxy(char *corpid, char *uid, char *domain, + char *url, char *param, char *seq_id); +extern int goBlock(char *corpid); +extern int goCancelBlock(char *corpid); + +/* + * init - Wrapper for safechatInit + * Initializes the SafeChat library. + */ +int init(char *path, char *my_id, void *reserved) { + (void)reserved; /* unused */ + return safechatInit(path, my_id); +} + +/* + * clearCache - Clears the key cache for a specific enterprise + * Returns 0 on success, non-zero on error. + * Note: This is a placeholder - actual implementation may vary. + */ +int clearCache(char *corpid) { + (void)corpid; /* unused for now */ + /* TODO: Implement actual cache clearing if needed */ + return 0; +} + +/* + * freeCryptoBuf - Frees a buffer allocated by the C library + * The C library allocates buffers with malloc, so we free with free. + */ +void freeCryptoBuf(void *buf) { + if (buf != NULL) { + free(buf); + } +} + +/* + * goProxyBridge - Bridge for call_proxy_func typedef + * Called by C library when a key request needs to be sent. + * Forwards to Go's goProxy which performs HTTP request + * and calls setResponse to feed back the key data. + */ +int goProxyBridge(char *corpid, char *uid, char *domain, + char *url, char *param, char *seq_id) { + return goProxy(corpid, uid, domain, url, param, seq_id); +} + +/* + * goBlockBridge - Bridge for block_crypto_func typedef + * Called by C library when enterprise key is restricted. + * Notifies Go layer of the restriction. + */ +int goBlockBridge(char *corpid) { + return goBlock(corpid); +} + +/* + * goCancelBlockBridge - Bridge for cancel_block_crypto_func typedef + * Called by C library when enterprise key restriction is lifted. + * Notifies Go layer to remove the restriction. + */ +int goCancelBlockBridge(char *corpid) { + return goCancelBlock(corpid); +} diff --git a/third_party/safechat-go-sdk/goproxy_bridge.h b/third_party/safechat-go-sdk/goproxy_bridge.h new file mode 100644 index 00000000..78744a77 --- /dev/null +++ b/third_party/safechat-go-sdk/goproxy_bridge.h @@ -0,0 +1,28 @@ +#ifndef GOPROXY_BRIDGE_H +#define GOPROXY_BRIDGE_H + +/* + * goproxy_bridge.h - CGO callback bridge declarations + * + * These bridge functions are called by the C library (safechat.c) + * and forward to Go exported functions via CGO. + * This indirection is required because CGO cannot directly pass + * Go function pointers to C code. + */ + +/* Proxy callback bridge - forwards key requests to Go HTTP client */ +int goProxyBridge(char *corpid, char *uid, char *domain, + char *url, char *param, char *seq_id); + +/* Block crypto callback bridge - notifies Go of key restriction */ +int goBlockBridge(char *corpid); + +/* Cancel block crypto callback bridge - notifies Go of restriction lift */ +int goCancelBlockBridge(char *corpid); + +/* Wrapper functions for CGO */ +int init(char *path, char *my_id, void *reserved); +int clearCache(char *corpid); +void freeCryptoBuf(void *buf); + +#endif /* GOPROXY_BRIDGE_H */ diff --git a/third_party/safechat-go-sdk/keyclient.go b/third_party/safechat-go-sdk/keyclient.go new file mode 100644 index 00000000..406190ea --- /dev/null +++ b/third_party/safechat-go-sdk/keyclient.go @@ -0,0 +1,155 @@ +package safechat + +import ( + "crypto/tls" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// httpLogBodyLimit is the maximum body length (bytes) included in debug logs. +// Beyond this, the body is truncated with a "..." marker so the log line stays +// readable. Full bodies are still passed to the C library unchanged. +const httpLogBodyLimit = 4096 + +// keyClient handles HTTP communication with the key server. +// It is responsible for sending key requests (triggered by C library's goProxy callback) +// and returning the JSON response to be fed back via setResponse. +// +// The code field holds a DingTalk authCode used for server authentication. +// It is required when: +// - Fetching keys for the first time (empty keystore) +// - Server-side key version rotation (C library detects version mismatch) +type keyClient struct { + httpClient *http.Client + code string // DingTalk authCode + keyServer string // Optional override for key server URL + logger Logger +} + +// newKeyClient creates a new key client with the given configuration. +func newKeyClient(cfg Config) *keyClient { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + // Allow connecting to enterprise private servers with self-signed certs + InsecureSkipVerify: true, + }, + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + DisableCompression: true, + } + + return &keyClient{ + httpClient: &http.Client{ + Timeout: cfg.HTTPTimeout, + Transport: transport, + }, + code: cfg.Code, + keyServer: cfg.KeyServer, + logger: cfg.Logger, + } +} + +func (kc *keyClient) doKeyRequest(fullURL, param, code string) (string, error) { + // Use override key server if configured + targetURL := fullURL + if kc.keyServer != "" { + targetURL = kc.keyServer + } + + var body string + if strings.HasPrefix(param, "param=") { + // V1 format: C library output already has "param=" prefix and all fields. + body = fmt.Sprintf("%s&code=%s", param, url.QueryEscape(code)) + } else { + body = fmt.Sprintf("%s&code=%s&appAlgVersion=1", param, url.QueryEscape(code)) + } + + // === Request logging (debug) === + // Log full request line, headers and body (truncated) so we can verify + // the URL, the URL-encoded payload and the auth code are shaped as expected. + if kc.logger != nil { + kc.logger.Debug("=== HTTP key request ===") + kc.logger.Debug("URL: POST %s", targetURL) + kc.logger.Debug("C-URL: %s (from C library)", fullURL) + kc.logger.Debug("Server: %s%s", + func() string { + if kc.keyServer != "" { + return kc.keyServer + " (override)" + } + return "(C-provided)" + }(), + "") + kc.logger.Debug("Headers: Content-Type=application/x-www-form-urlencoded, User-Agent=SafeChat-Go-SDK/1.0") + kc.logger.Debug("Code (auth_token, length=%d): %s", len(code), previewString(code, 64)) + kc.logger.Debug("Body (length=%d): %s", len(body), previewString(body, httpLogBodyLimit)) + } + + req, err := http.NewRequest("POST", targetURL, strings.NewReader(body)) + if err != nil { + if kc.logger != nil { + kc.logger.Error("create request failed: %v", err) + } + return "", fmt.Errorf("create request failed: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "SafeChat-Go-SDK/1.0") + + // Capture timing so we can see if the server is slow / timing out. + start := time.Now() + resp, err := kc.httpClient.Do(req) + if err != nil { + if kc.logger != nil { + kc.logger.Error("HTTP request failed after %s: %v", time.Since(start), err) + } + return "", fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + if kc.logger != nil { + kc.logger.Error("read response body failed after %s: %v", time.Since(start), err) + } + return "", fmt.Errorf("read response body failed: %w", err) + } + duration := time.Since(start) + + // === Response logging (debug on success, info on non-2xx) === + if kc.logger != nil { + kc.logger.Debug("=== HTTP key response ===") + kc.logger.Debug("Status: %d %s (took %s)", resp.StatusCode, http.StatusText(resp.StatusCode), duration) + kc.logger.Debug("Headers: Content-Type=%s, Content-Length=%d", resp.Header.Get("Content-Type"), len(respBody)) + kc.logger.Debug("Body (length=%d): %s", len(respBody), previewString(string(respBody), httpLogBodyLimit)) + } + + if resp.StatusCode != http.StatusOK { + if kc.logger != nil { + kc.logger.Error("key server returned HTTP %d %s (took %s, body length=%d): %s", + resp.StatusCode, http.StatusText(resp.StatusCode), duration, len(respBody), + previewString(string(respBody), httpLogBodyLimit)) + } + return "", fmt.Errorf("key server returned HTTP %d", resp.StatusCode) + } + + return string(respBody), nil +} + +// previewString returns s unchanged when it fits in max bytes; otherwise it +// returns the first max bytes followed by "...[truncated, total=N]". This is +// used to keep HTTP request/response log lines readable for very large bodies +// while still preserving the head of the payload for debugging. +func previewString(s string, max int) string { + if max <= 0 || len(s) <= max { + return s + } + return s[:max] + fmt.Sprintf("...[truncated, total=%d]", len(s)) +} + +// updateCode updates the authentication code (may change during runtime). +func (kc *keyClient) updateCode(code string) { + kc.code = code +} diff --git a/third_party/safechat-go-sdk/lib/darwin_amd64/libsafechat.a b/third_party/safechat-go-sdk/lib/darwin_amd64/libsafechat.a new file mode 100644 index 00000000..045ef5b8 Binary files /dev/null and b/third_party/safechat-go-sdk/lib/darwin_amd64/libsafechat.a differ diff --git a/third_party/safechat-go-sdk/lib/darwin_arm64/libsafechat.a b/third_party/safechat-go-sdk/lib/darwin_arm64/libsafechat.a new file mode 100644 index 00000000..e99dd04b Binary files /dev/null and b/third_party/safechat-go-sdk/lib/darwin_arm64/libsafechat.a differ diff --git a/third_party/safechat-go-sdk/lib/linux_amd64/libsafechat.a b/third_party/safechat-go-sdk/lib/linux_amd64/libsafechat.a new file mode 100644 index 00000000..d398fa92 Binary files /dev/null and b/third_party/safechat-go-sdk/lib/linux_amd64/libsafechat.a differ diff --git a/third_party/safechat-go-sdk/lib/linux_arm64/libsafechat.a b/third_party/safechat-go-sdk/lib/linux_arm64/libsafechat.a new file mode 100644 index 00000000..8266b545 Binary files /dev/null and b/third_party/safechat-go-sdk/lib/linux_arm64/libsafechat.a differ diff --git a/third_party/safechat-go-sdk/lib/windows_amd64/libsafechat.a b/third_party/safechat-go-sdk/lib/windows_amd64/libsafechat.a new file mode 100644 index 00000000..e6cb0fb4 Binary files /dev/null and b/third_party/safechat-go-sdk/lib/windows_amd64/libsafechat.a differ diff --git a/third_party/safechat-go-sdk/safechat.go b/third_party/safechat-go-sdk/safechat.go new file mode 100644 index 00000000..2646fb9e --- /dev/null +++ b/third_party/safechat-go-sdk/safechat.go @@ -0,0 +1,615 @@ +// Package safechat provides encryption/decryption capabilities for the + +// Basic usage: +// +// client, err := safechat.New(safechat.Config{ +// DataPath: "/path/to/keystore", +// UserID: "user123", +// Code: "authCode ", // authCode +// }) +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// ciphertext, err := client.EncryptMsg("corp_id", "staff_id", []byte("hello")) +// +// The Code field must be a valid DingTalk authCode. It is required +// when fetching keys from the server (first-time use or key version rotation). +package safechat + +/* +#include "csrc/safechat.h" +#include "goproxy_bridge.h" +#include +#include +*/ +import "C" +import ( + "fmt" + "sync" + "unsafe" + + "github.com/google/uuid" +) + +// Client is the main SafeChat encryption client. +// It wraps the C library and provides a thread-safe Go API. +// +// IMPORTANT: Only one Client instance can exist per process because +// the underlying C library uses global state. Creating a second Client +// will return ErrAlreadyInitialized. +// +// All public methods are safe for concurrent use from multiple goroutines. +// Internal synchronization uses a two-lock design: +// - mu: serializes all C library calls (prevents C global state corruption) +// - kcMu: protects keyClient state during HTTP requests (used in goProxy callback) +type Client struct { + cfg Config + mu sync.Mutex // Serializes all C library calls + kcMu sync.Mutex // Protects keyClient during HTTP key requests + kc *keyClient // HTTP client for key server communication + inited bool // Whether C library init() has been called + blockedCorps sync.Map // map[string]bool - enterprises with restricted keys +} + +// New creates a new SafeChat client and initializes the underlying C library. +// +// The Config.DataPath directory will be used to store encryption keys and +// related metadata. It must exist and be writable. +// +// Returns ErrAlreadyInitialized if called more than once per process. +func New(cfg Config) (*Client, error) { + cfg = defaultConfig(cfg) + if err := cfg.validate(); err != nil { + return nil, err + } + + // Check if already initialized (C library is singleton) + if getGlobalClient() != nil { + return nil, ErrAlreadyInitialized + } + + c := &Client{ + cfg: cfg, + kc: newKeyClient(cfg), + } + + // Register globally for CGO callbacks before calling init + registerGlobalClient(c) + + // Initialize C library + if err := c.cInit(); err != nil { + globalClient.Store((*Client)(nil)) + return nil, fmt.Errorf("safechat init failed: %w", err) + } + + c.inited = true + return c, nil +} + +// Close releases resources held by the client. +// After Close is called, no other methods should be called. +func (c *Client) Close() { + c.mu.Lock() + defer c.mu.Unlock() + c.inited = false + // Clear the global singleton so a new Client can be created after Close. + // The C library has no explicit cleanup function; keys are persisted to + // disk and process memory is reclaimed on exit. + globalClient.Store((*Client)(nil)) +} + +// EncryptMsg encrypts a plaintext message for the given enterprise. +// +// Returns the ciphertext in the standard SafeChat format: +// base64(encrypted_data)||key_version||method_num||plain_length +// +// If the encryption key is not yet available, the SDK will automatically +// request it from the key server (via goProxy callback) and retry. +func (c *Client) EncryptMsg(corpID, staffID string, plaintext []byte) ([]byte, error) { + if !c.inited { + return nil, ErrNotInitialized + } + if len(plaintext) == 0 { + return nil, fmt.Errorf("safechat: plaintext cannot be empty") + } + + c.mu.Lock() + defer c.mu.Unlock() + + for i := 0; i <= c.cfg.MaxRetry; i++ { + result, err := c.cEncryptData(corpID, staffID, plaintext) + if err == nil { + return result, nil + } + + // Check if it's a "key requested" status - retry + if cerr, ok := err.(*CError); ok && cerr.Code == cSendRequestParamOK { + // goProxy was called and setResponse was invoked synchronously. + // Next iteration should find the key in local cache. + continue + } + + // Check for key restriction + if cerr, ok := err.(*CError); ok && cerr.Code == cEncryptDataKeyNegtive { + return nil, ErrKeyRestricted + } + + return nil, err + } + + return nil, ErrMaxRetryExceeded +} + +// DecryptMsg decrypts a ciphertext message. +// +// The ciphertext must be in the standard SafeChat format: +// base64(encrypted_data)||key_version||method_num||plain_length +func (c *Client) DecryptMsg(corpID, staffID string, ciphertext []byte) ([]byte, error) { + if !c.inited { + return nil, ErrNotInitialized + } + if len(ciphertext) == 0 { + return nil, fmt.Errorf("safechat: ciphertext cannot be empty") + } + + c.mu.Lock() + defer c.mu.Unlock() + + for i := 0; i <= c.cfg.MaxRetry; i++ { + result, err := c.cDecryptData(corpID, staffID, ciphertext) + if err == nil { + return result, nil + } + + if cerr, ok := err.(*CError); ok && cerr.Code == cSendRequestParamOK { + continue + } + if cerr, ok := err.(*CError); ok && cerr.Code == cDecryptDataKeyNegtive { + return nil, ErrKeyRestricted + } + + return nil, err + } + + return nil, ErrMaxRetryExceeded +} + +// EncryptFile encrypts a file from srcPath to dstPath. +// +// The encrypted file uses a 12-byte header (msg_HandInfo_t) followed by +// SM4-ECB encrypted data in 8KiB blocks. +func (c *Client) EncryptFile(corpID, staffID, srcPath, dstPath string) error { + if !c.inited { + return ErrNotInitialized + } + + c.mu.Lock() + defer c.mu.Unlock() + + for i := 0; i <= c.cfg.MaxRetry; i++ { + err := c.cEncryptFile(corpID, staffID, srcPath, dstPath) + if err == nil { + return nil + } + + if cerr, ok := err.(*CError); ok && cerr.Code == cSendRequestParamOK { + continue + } + + return err + } + + return ErrMaxRetryExceeded +} + +// DecryptFile decrypts a file from srcPath to dstPath. +func (c *Client) DecryptFile(corpID, staffID, srcPath, dstPath string) error { + if !c.inited { + return ErrNotInitialized + } + + c.mu.Lock() + defer c.mu.Unlock() + + for i := 0; i <= c.cfg.MaxRetry; i++ { + err := c.cDecryptFile(corpID, staffID, srcPath, dstPath) + if err == nil { + return nil + } + + if cerr, ok := err.(*CError); ok && cerr.Code == cSendRequestParamOK { + continue + } + + return err + } + + return ErrMaxRetryExceeded +} + +// EncryptBuffer encrypts binary data in memory. +// +// The result includes a 12-byte header followed by SM4-ECB encrypted content. +func (c *Client) EncryptBuffer(corpID, staffID string, data []byte) ([]byte, error) { + if !c.inited { + return nil, ErrNotInitialized + } + if len(data) == 0 { + return nil, fmt.Errorf("safechat: data cannot be empty") + } + + c.mu.Lock() + defer c.mu.Unlock() + + for i := 0; i <= c.cfg.MaxRetry; i++ { + result, err := c.cEncryptBuffer(corpID, staffID, data) + if err == nil { + return result, nil + } + + if cerr, ok := err.(*CError); ok && cerr.Code == cSendRequestParamOK { + continue + } + + return nil, err + } + + return nil, ErrMaxRetryExceeded +} + +// DecryptBuffer decrypts binary data in memory. +func (c *Client) DecryptBuffer(corpID, staffID string, data []byte) ([]byte, error) { + if !c.inited { + return nil, ErrNotInitialized + } + if len(data) == 0 { + return nil, fmt.Errorf("safechat: data cannot be empty") + } + + c.mu.Lock() + defer c.mu.Unlock() + + for i := 0; i <= c.cfg.MaxRetry; i++ { + result, err := c.cDecryptBuffer(corpID, staffID, data) + if err == nil { + return result, nil + } + + if cerr, ok := err.(*CError); ok && cerr.Code == cSendRequestParamOK { + continue + } + + return nil, err + } + + return nil, ErrMaxRetryExceeded +} + +// SetResponse manually injects a key server response into the C library. +// This is an advanced API for cases where the caller handles HTTP +// communication externally. +func (c *Client) SetResponse(corpID, jsonStr string) error { + if !c.inited { + return ErrNotInitialized + } + + c.mu.Lock() + defer c.mu.Unlock() + + return c.cSetResponse(corpID, jsonStr) +} + +// SetPushData processes a server push notification (key update, etc). +func (c *Client) SetPushData(corpID, staffID, pushData string) error { + if !c.inited { + return ErrNotInitialized + } + + c.mu.Lock() + defer c.mu.Unlock() + + return c.cSetPushData(corpID, staffID, pushData) +} + +// ClearCache clears the local key cache for a specific enterprise. +func (c *Client) ClearCache(corpID string) error { + if !c.inited { + return ErrNotInitialized + } + + c.mu.Lock() + defer c.mu.Unlock() + + cCorpID := C.CString(corpID) + defer C.free(unsafe.Pointer(cCorpID)) + + ret := C.clearCache(cCorpID) + return mapCError(int(ret)) +} + +// IsBlocked returns true if the given enterprise's key is restricted. +func (c *Client) IsBlocked(corpID string) bool { + _, ok := c.blockedCorps.Load(corpID) + return ok +} + +// UpdateCode updates the DingTalk authentication code at runtime. +func (c *Client) UpdateCode(code string) { + c.kcMu.Lock() + defer c.kcMu.Unlock() + c.cfg.Code = code + c.kc.updateCode(code) +} + +// ============================================================ +// CGO wrapper methods (called with c.mu held) +// ============================================================ + +func (c *Client) cInit() error { + cPath := C.CString(c.cfg.DataPath) + cUserID := C.CString(c.cfg.UserID) + defer C.free(unsafe.Pointer(cPath)) + defer C.free(unsafe.Pointer(cUserID)) + + ret := C.init(cPath, cUserID, nil) + return mapCError(int(ret)) +} + +func (c *Client) cEncryptData(corpID, staffID string, plaintext []byte) ([]byte, error) { + cCorpID := C.CString(corpID) + cStaffID := C.CString(staffID) + seqID := uuid.New().String() + cSeqID := C.CString(seqID) + defer C.free(unsafe.Pointer(cCorpID)) + defer C.free(unsafe.Pointer(cStaffID)) + defer C.free(unsafe.Pointer(cSeqID)) + + var outBuf *C.uchar + var outLen C.uint + + ret := C.encryptData( + cCorpID, + cStaffID, + (*C.uchar)(unsafe.Pointer(&plaintext[0])), + C.uint(len(plaintext)), + nil, // id - NULL for group messages + &outBuf, + &outLen, + cSeqID, + (C.call_proxy_func)(C.goProxyBridge), + ) + + retCode := int(ret) + if retCode != cFunctionOK { + return nil, mapCError(retCode) + } + + // Copy result to Go slice and free C buffer + result := C.GoBytes(unsafe.Pointer(outBuf), C.int(outLen)) + C.freeCryptoBuf(unsafe.Pointer(outBuf)) + return result, nil +} + +func (c *Client) cDecryptData(corpID, staffID string, ciphertext []byte) ([]byte, error) { + cCorpID := C.CString(corpID) + cStaffID := C.CString(staffID) + seqID := uuid.New().String() + cSeqID := C.CString(seqID) + defer C.free(unsafe.Pointer(cCorpID)) + defer C.free(unsafe.Pointer(cStaffID)) + defer C.free(unsafe.Pointer(cSeqID)) + + var outBuf *C.uchar + var outLen C.uint + + // The C layer treats the ciphertext as a NUL-terminated C string + // (sscanf/strtok). Go byte slices are not NUL-terminated and strtok would + // mutate the caller's buffer, so pass a NUL-terminated private copy. + cbuf := make([]byte, len(ciphertext)+1) + copy(cbuf, ciphertext) + + ret := C.decryptData( + cCorpID, + cStaffID, + (*C.uchar)(unsafe.Pointer(&cbuf[0])), + C.uint(len(ciphertext)), + nil, // id + &outBuf, + &outLen, + cSeqID, + (C.call_proxy_func)(C.goProxyBridge), + ) + + retCode := int(ret) + if retCode != cFunctionOK { + return nil, mapCError(retCode) + } + + result := C.GoBytes(unsafe.Pointer(outBuf), C.int(outLen)) + C.freeCryptoBuf(unsafe.Pointer(outBuf)) + return result, nil +} + +func (c *Client) cEncryptFile(corpID, staffID, srcPath, dstPath string) error { + cCorpID := C.CString(corpID) + cStaffID := C.CString(staffID) + cID := C.CString("") + cSrcPath := C.CString(srcPath) + cDstPath := C.CString(dstPath) + seqID := uuid.New().String() + cSeqID := C.CString(seqID) + defer C.free(unsafe.Pointer(cCorpID)) + defer C.free(unsafe.Pointer(cStaffID)) + defer C.free(unsafe.Pointer(cID)) + defer C.free(unsafe.Pointer(cSrcPath)) + defer C.free(unsafe.Pointer(cDstPath)) + defer C.free(unsafe.Pointer(cSeqID)) + + ret := C.encryptFile( + cCorpID, + cStaffID, + cID, + cSrcPath, + cDstPath, + cSeqID, + (C.call_proxy_func)(C.goProxyBridge), + ) + + retCode := int(ret) + if retCode != cFunctionOK { + return mapCError(retCode) + } + return nil +} + +func (c *Client) cDecryptFile(corpID, staffID, srcPath, dstPath string) error { + cCorpID := C.CString(corpID) + cStaffID := C.CString(staffID) + cID := C.CString("") + cSrcPath := C.CString(srcPath) + cDstPath := C.CString(dstPath) + seqID := uuid.New().String() + cSeqID := C.CString(seqID) + defer C.free(unsafe.Pointer(cCorpID)) + defer C.free(unsafe.Pointer(cStaffID)) + defer C.free(unsafe.Pointer(cID)) + defer C.free(unsafe.Pointer(cSrcPath)) + defer C.free(unsafe.Pointer(cDstPath)) + defer C.free(unsafe.Pointer(cSeqID)) + + ret := C.decryptFile( + cCorpID, + cStaffID, + cID, + cSrcPath, + cDstPath, + cSeqID, + (C.call_proxy_func)(C.goProxyBridge), + ) + + retCode := int(ret) + if retCode != cFunctionOK { + return mapCError(retCode) + } + return nil +} + +func (c *Client) cEncryptBuffer(corpID, staffID string, data []byte) ([]byte, error) { + cCorpID := C.CString(corpID) + cStaffID := C.CString(staffID) + cID := C.CString("") + seqID := uuid.New().String() + cSeqID := C.CString(seqID) + defer C.free(unsafe.Pointer(cCorpID)) + defer C.free(unsafe.Pointer(cStaffID)) + defer C.free(unsafe.Pointer(cID)) + defer C.free(unsafe.Pointer(cSeqID)) + + var outBuf *C.uchar + var outLen C.uint + + ret := C.encryptBuffer( + cCorpID, + cStaffID, + (*C.uchar)(unsafe.Pointer(&data[0])), + C.uint(len(data)), + cID, + &outBuf, + &outLen, + cSeqID, + (C.call_proxy_func)(C.goProxyBridge), + ) + + retCode := int(ret) + if retCode != cFunctionOK { + return nil, mapCError(retCode) + } + + result := C.GoBytes(unsafe.Pointer(outBuf), C.int(outLen)) + C.freeCryptoBuf(unsafe.Pointer(outBuf)) + return result, nil +} + +func (c *Client) cDecryptBuffer(corpID, staffID string, data []byte) ([]byte, error) { + cCorpID := C.CString(corpID) + cStaffID := C.CString(staffID) + cID := C.CString("") + seqID := uuid.New().String() + cSeqID := C.CString(seqID) + defer C.free(unsafe.Pointer(cCorpID)) + defer C.free(unsafe.Pointer(cStaffID)) + defer C.free(unsafe.Pointer(cID)) + defer C.free(unsafe.Pointer(cSeqID)) + + var outBuf *C.uchar + var outLen C.uint + + ret := C.decryptBuffer( + cCorpID, + cStaffID, + (*C.uchar)(unsafe.Pointer(&data[0])), + C.uint(len(data)), + cID, + &outBuf, + &outLen, + cSeqID, + (C.call_proxy_func)(C.goProxyBridge), + ) + + retCode := int(ret) + if retCode != cFunctionOK { + return nil, mapCError(retCode) + } + + result := C.GoBytes(unsafe.Pointer(outBuf), C.int(outLen)) + C.freeCryptoBuf(unsafe.Pointer(outBuf)) + return result, nil +} + +func (c *Client) cSetResponse(corpID, jsonStr string) error { + cCorpID := C.CString(corpID) + cJSON := C.CString(jsonStr) + defer C.free(unsafe.Pointer(cCorpID)) + defer C.free(unsafe.Pointer(cJSON)) + + ret := C.setResponse( + cCorpID, + cJSON, + (C.block_crypto_func)(C.goBlockBridge), + ) + + retCode := int(ret) + if retCode != cFunctionOK { + return mapCError(retCode) + } + return nil +} + +func (c *Client) cSetPushData(corpID, staffID, pushData string) error { + cCorpID := C.CString(corpID) + cStaffID := C.CString(staffID) + cPushData := C.CString(pushData) + seqID := uuid.New().String() + cSeqID := C.CString(seqID) + defer C.free(unsafe.Pointer(cCorpID)) + defer C.free(unsafe.Pointer(cStaffID)) + defer C.free(unsafe.Pointer(cPushData)) + defer C.free(unsafe.Pointer(cSeqID)) + + ret := C.setPushData( + cCorpID, + cStaffID, + cPushData, + cSeqID, + (C.call_proxy_func)(C.goProxyBridge), + (C.cancel_block_crypto_func)(C.goCancelBlockBridge), + ) + + retCode := int(ret) + if retCode != cFunctionOK && retCode != cSendRequestParamOK { + return mapCError(retCode) + } + return nil +} diff --git a/third_party/safechat-go-sdk/version.go b/third_party/safechat-go-sdk/version.go new file mode 100644 index 00000000..bf2c64eb --- /dev/null +++ b/third_party/safechat-go-sdk/version.go @@ -0,0 +1,5 @@ +package safechat + +// Version is the current version of the SafeChat Go SDK. +// This version follows semantic versioning (semver). +const Version = "1.0.0"