diff --git a/.changeset/26710-signed-connector-auth-tencentcloud.md b/.changeset/26710-signed-connector-auth-tencentcloud.md new file mode 100644 index 00000000000..57df01bc17e --- /dev/null +++ b/.changeset/26710-signed-connector-auth-tencentcloud.md @@ -0,0 +1,24 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-automation': minor +--- + +feat(spec): `tencentcloud-tc3-hmac-sha256` joins the `signed` connector-instance auth schemes (ADR-0143 D2.3, #26710) + +Clause-②: yes (widening) +Metadata-protocol: added + + + +A declarative connector instance can now declare Tencent Cloud's API 3.0 signature (v3) as its signing scheme. It joins Alibaba Cloud's V3 signature, which shipped with the `signed` kind. + +- **`@objectstack/spec`** + - `ConnectorSigningSchemeSchema` gains `tencentcloud-tc3-hmac-sha256`. + - The `signed` union gains its closed shape, `{ type: 'signed', scheme: 'tencentcloud-tc3-hmac-sha256', keyId, credentialRef }`. `keyId` is the SecretId. The SecretKey is only ever a `credentialRef`. + - The same publish refusals apply as for the first scheme: an inline key spelling by name, an authored `signature`, and `signed` on `provider: 'mcp'`. `secretId` is answered with `keyId`. An unknown scheme's refusal now lists both shipped schemes. + - `ConnectorRequestSigner.scheme` is typed as `ConnectorSigningScheme` rather than `string`. The interface ships for the first time in this release, together with the first scheme. +- **`@objectstack/service-automation`** + - The signer registry binds a `tencentcloud-tc3-hmac-sha256` instance's key to its TC3-HMAC-SHA256 signer. + - Every attempt is signed with a fresh `X-TC-Timestamp`, over `content-type` and `host`. The scope's service is the first label of the endpoint host. + - A request without a content type is signed and sent as `application/x-www-form-urlencoded`. + - The key never leaves the host. diff --git a/content/docs/automation/connectors.mdx b/content/docs/automation/connectors.mdx index 760ea535a62..016c3e51661 100644 --- a/content/docs/automation/connectors.mdx +++ b/content/docs/automation/connectors.mdx @@ -483,12 +483,21 @@ auth: { keyId: 'YourAccessKeyId', // the AccessKey ID: public, stays in metadata credentialRef: 'ALIYUN_ACCESS_KEY_SECRET', } + +// Tencent Cloud API 3.0, signature v3 (TC3-HMAC-SHA256): +auth: { + type: 'signed', + scheme: 'tencentcloud-tc3-hmac-sha256', + keyId: 'YourSecretId', // the SecretId: public, stays in metadata + credentialRef: 'TENCENT_SECRET_KEY', +} ``` What the platform does with it: - **Every request is signed, each attempt fresh.** A retry is signed again with - a new nonce and date, so a scheme that refuses a replayed nonce accepts it. + a new nonce and date, so a scheme that refuses a replayed nonce or a stale + timestamp accepts it. - **The key never reaches the provider.** The automation service binds the resolved key to the scheme's signer at materialization and hands the provider the signer only. A datasource cannot borrow a `signed` instance @@ -501,6 +510,14 @@ What the platform does with it: `x-acs-content-sha256` and `Authorization`. The API's own action and version are yours to send, as `x-acs-action` / `x-acs-version` in `defaultHeaders` (rest) or in each call's headers. +- **`tencentcloud-tc3-hmac-sha256`** signs `content-type` and `host`, and sets + `X-TC-Timestamp` and `Authorization`. The service in the signature's scope is + the first label of the endpoint host (`cvm.tencentcloudapi.com`, or a + regional `cvm.ap-guangzhou.tencentcloudapi.com`, is `cvm`), so point + `baseUrl` at the product's own endpoint. `X-TC-Action`, `X-TC-Version` and + `X-TC-Region` are yours to send and are not signed. A request without a body + is sent with `Content-Type: application/x-www-form-urlencoded`, the type it + is signed with. What the publish door refuses: @@ -509,7 +526,8 @@ What the publish door refuses: signer and its test vectors, so a scheme the server cannot run is never accepted. - **An inline key**: `secret`, `privateKey`, `secretKey` or `accessKeySecret` - is refused by name, pointing at `credentialRef`. An authored `signature` is + is refused by name, pointing at `credentialRef`. The vendors' words for the + key id (`accessKeyId`, `secretId`) are answered with `keyId`. An authored `signature` is refused too: it is computed for every request. - **`provider: 'mcp'`**, at `auth.type`: an MCP server is not signed per request. Declare `provider: 'rest'` or `provider: 'openapi'`. diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index cab4ec45df8..5e46543ac0e 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1569 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1570 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -25,7 +25,7 @@ counts are sums of the rows they head. Regenerate with | [Automation Protocol](/docs/references/automation) | 15 | 77 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 31 | 174 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | -| [Integration Protocol](/docs/references/integration) | 1 | 21 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | +| [Integration Protocol](/docs/references/integration) | 1 | 22 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 29 | 151 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [Marketplace Protocol](/docs/references/marketplace) | 4 | 30 | The package & marketplace format — package identity and versions, listing, publish, review, search, install, template manifests. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 172 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **203** | **1569** | 14 protocol modules | +| **Total** | **203** | **1570** | 14 protocol modules | --- @@ -191,13 +191,13 @@ Users and accounts, organizations, positions, SCIM provisioning. ## Integration Protocol -**Source:** `packages/spec/src/integration/` · **Import:** `@objectstack/spec/integration` · **1 page, 21 schemas** +**Source:** `packages/spec/src/integration/` · **Import:** `@objectstack/spec/integration` · **1 page, 22 schemas** The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | File | Schemas | | :--- | :--- | -| [`connector.zod.ts`](/docs/references/integration/connector) | `Connector`, `ConnectorAction`, `ConnectorActionEffect`, `ConnectorAppCredentialVendor`, `ConnectorInstanceAPIKeyAuth`, `ConnectorInstanceAppCredentialAuth`, `ConnectorInstanceAuth`, `ConnectorInstanceBasicAuth`, `ConnectorInstanceBearerAuth`, `ConnectorInstanceNoAuth`, `ConnectorInstanceOAuth2Auth`, `ConnectorInstanceOAuth2AuthorizationCodeAuth`, `ConnectorInstanceOAuth2ClientCredentialsAuth`, `ConnectorInstanceSignedAliyunAcs3Auth`, `ConnectorInstanceSignedAuth`, `ConnectorOAuth2Grant`, `ConnectorRetryStrategy`, `ConnectorSigningScheme`, `ConnectorType`, `DeclarativeConnectorEntry`, `RetryConfig` | +| [`connector.zod.ts`](/docs/references/integration/connector) | `Connector`, `ConnectorAction`, `ConnectorActionEffect`, `ConnectorAppCredentialVendor`, `ConnectorInstanceAPIKeyAuth`, `ConnectorInstanceAppCredentialAuth`, `ConnectorInstanceAuth`, `ConnectorInstanceBasicAuth`, `ConnectorInstanceBearerAuth`, `ConnectorInstanceNoAuth`, `ConnectorInstanceOAuth2Auth`, `ConnectorInstanceOAuth2AuthorizationCodeAuth`, `ConnectorInstanceOAuth2ClientCredentialsAuth`, `ConnectorInstanceSignedAliyunAcs3Auth`, `ConnectorInstanceSignedAuth`, `ConnectorInstanceSignedTencentCloudTc3Auth`, `ConnectorOAuth2Grant`, `ConnectorRetryStrategy`, `ConnectorSigningScheme`, `ConnectorType`, `DeclarativeConnectorEntry`, `RetryConfig` | --- diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx index bc75ad32077..5bf97653293 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -165,8 +165,8 @@ an exception for itself.) ## TypeScript Usage ```typescript -import { ConnectorSchema, ConnectorActionSchema, ConnectorActionEffectSchema, ConnectorAppCredentialVendorSchema, ConnectorInstanceAPIKeyAuthSchema, ConnectorInstanceAppCredentialAuthSchema, ConnectorInstanceAuthSchema, ConnectorInstanceBasicAuthSchema, ConnectorInstanceBearerAuthSchema, ConnectorInstanceNoAuthSchema, ConnectorInstanceOAuth2AuthSchema, ConnectorInstanceOAuth2AuthorizationCodeAuthSchema, ConnectorInstanceOAuth2ClientCredentialsAuthSchema, ConnectorInstanceSignedAliyunAcs3AuthSchema, ConnectorInstanceSignedAuthSchema, ConnectorOAuth2GrantSchema, ConnectorRetryStrategySchema, ConnectorSigningSchemeSchema, ConnectorTypeSchema, DeclarativeConnectorEntrySchema, RetryConfigSchema } from '@objectstack/spec/integration'; -import type { Connector, ConnectorAction, ConnectorActionEffect, ConnectorAppCredentialVendor, ConnectorInstanceAPIKeyAuth, ConnectorInstanceAppCredentialAuth, ConnectorInstanceAuth, ConnectorInstanceBasicAuth, ConnectorInstanceBearerAuth, ConnectorInstanceNoAuth, ConnectorInstanceOAuth2Auth, ConnectorInstanceOAuth2AuthorizationCodeAuth, ConnectorInstanceOAuth2ClientCredentialsAuth, ConnectorInstanceSignedAliyunAcs3Auth, ConnectorInstanceSignedAuth, ConnectorOAuth2Grant, ConnectorRetryStrategy, ConnectorSigningScheme, ConnectorType, DeclarativeConnectorEntry, RetryConfig } from '@objectstack/spec/integration'; +import { ConnectorSchema, ConnectorActionSchema, ConnectorActionEffectSchema, ConnectorAppCredentialVendorSchema, ConnectorInstanceAPIKeyAuthSchema, ConnectorInstanceAppCredentialAuthSchema, ConnectorInstanceAuthSchema, ConnectorInstanceBasicAuthSchema, ConnectorInstanceBearerAuthSchema, ConnectorInstanceNoAuthSchema, ConnectorInstanceOAuth2AuthSchema, ConnectorInstanceOAuth2AuthorizationCodeAuthSchema, ConnectorInstanceOAuth2ClientCredentialsAuthSchema, ConnectorInstanceSignedAliyunAcs3AuthSchema, ConnectorInstanceSignedAuthSchema, ConnectorInstanceSignedTencentCloudTc3AuthSchema, ConnectorOAuth2GrantSchema, ConnectorRetryStrategySchema, ConnectorSigningSchemeSchema, ConnectorTypeSchema, DeclarativeConnectorEntrySchema, RetryConfigSchema } from '@objectstack/spec/integration'; +import type { Connector, ConnectorAction, ConnectorActionEffect, ConnectorAppCredentialVendor, ConnectorInstanceAPIKeyAuth, ConnectorInstanceAppCredentialAuth, ConnectorInstanceAuth, ConnectorInstanceBasicAuth, ConnectorInstanceBearerAuth, ConnectorInstanceNoAuth, ConnectorInstanceOAuth2Auth, ConnectorInstanceOAuth2AuthorizationCodeAuth, ConnectorInstanceOAuth2ClientCredentialsAuth, ConnectorInstanceSignedAliyunAcs3Auth, ConnectorInstanceSignedAuth, ConnectorInstanceSignedTencentCloudTc3Auth, ConnectorOAuth2Grant, ConnectorRetryStrategy, ConnectorSigningScheme, ConnectorType, DeclarativeConnectorEntry, RetryConfig } from '@objectstack/spec/integration'; // Validate data const result = ConnectorSchema.parse(data); @@ -293,7 +293,7 @@ const result = ConnectorSchema.parse(data); | **appId** | `string` | ✅ | The vendor's app identifier (not a secret): Feishu `app_id`, DingTalk `appKey`, WeCom `corpid`. | | **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the app secret (WeCom: the application `corpsecret`). Never an inline secret. | -### Nested Shape: `Connector.auth[option 7]` +### Nested Shape: `Connector.auth[option 7][scheme='aliyun-acs3-hmac-sha256']` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -302,6 +302,15 @@ const result = ConnectorSchema.parse(data); | **keyId** | `string` | ✅ | The AccessKey ID (not a secret); sent in the `Credential` of every signature. | | **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the AccessKey secret, the HMAC key every request is signed with. Never an inline secret. | +### Nested Shape: `Connector.auth[option 7][scheme='tencentcloud-tc3-hmac-sha256']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'signed'` | ✅ | | +| **scheme** | `'tencentcloud-tc3-hmac-sha256'` | ✅ | Tencent Cloud API 3.0 signature v3 (TC3-HMAC-SHA256). | +| **keyId** | `string` | ✅ | The SecretId (not a secret); sent in the `Credential` of every signature. | +| **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the SecretKey every request's signing key is derived from. Never an inline secret. | + ### Nested Shape: `Connector.actions[number]` | Property | Type | Required | Description | @@ -481,7 +490,7 @@ Type: `{ type: 'oauth2'; tokenUrl?: string; scopes?: string[]; resource?: string #### Option 7 -Type: `{ type: 'signed'; scheme: 'aliyun-acs3-hmac-sha256'; keyId: string; credentialRef: string }` +Type: `{ type: 'signed'; scheme: 'aliyun-acs3-hmac-sha256'; keyId: string; credentialRef: string } | { type: 'signed'; scheme: 'tencentcloud-tc3-hmac-sha256'; keyId: string; credentialRef: string }` --- @@ -640,6 +649,35 @@ This schema accepts one of the following structures: --- +#### Option 2 + +**Type:** `signed` + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'signed'` | ✅ | | +| **scheme** | `'tencentcloud-tc3-hmac-sha256'` | ✅ | Tencent Cloud API 3.0 signature v3 (TC3-HMAC-SHA256). | +| **keyId** | `string` | ✅ | The SecretId (not a secret); sent in the `Credential` of every signature. | +| **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the SecretKey every request's signing key is derived from. Never an inline secret. | + +--- + + +--- + +## ConnectorInstanceSignedTencentCloudTc3Auth + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'signed'` | ✅ | | +| **scheme** | `'tencentcloud-tc3-hmac-sha256'` | ✅ | Tencent Cloud API 3.0 signature v3 (TC3-HMAC-SHA256). | +| **keyId** | `string` | ✅ | The SecretId (not a secret); sent in the `Credential` of every signature. | +| **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the SecretKey every request's signing key is derived from. Never an inline secret. | + --- @@ -671,11 +709,12 @@ Retry strategy ## ConnectorSigningScheme -Request-signing scheme: `aliyun-acs3-hmac-sha256` (Alibaba Cloud OpenAPI V3 signature). +Request-signing scheme: `aliyun-acs3-hmac-sha256` (Alibaba Cloud OpenAPI V3 signature) or `tencentcloud-tc3-hmac-sha256` (Tencent Cloud API 3.0 signature v3). ### Allowed Values * `aliyun-acs3-hmac-sha256` +* `tencentcloud-tc3-hmac-sha256` --- @@ -815,7 +854,7 @@ Connector type | **appId** | `string` | ✅ | The vendor's app identifier (not a secret): Feishu `app_id`, DingTalk `appKey`, WeCom `corpid`. | | **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the app secret (WeCom: the application `corpsecret`). Never an inline secret. | -### Nested Shape: `DeclarativeConnectorEntry.auth[option 7]` +### Nested Shape: `DeclarativeConnectorEntry.auth[option 7][scheme='aliyun-acs3-hmac-sha256']` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -824,6 +863,15 @@ Connector type | **keyId** | `string` | ✅ | The AccessKey ID (not a secret); sent in the `Credential` of every signature. | | **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the AccessKey secret, the HMAC key every request is signed with. Never an inline secret. | +### Nested Shape: `DeclarativeConnectorEntry.auth[option 7][scheme='tencentcloud-tc3-hmac-sha256']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'signed'` | ✅ | | +| **scheme** | `'tencentcloud-tc3-hmac-sha256'` | ✅ | Tencent Cloud API 3.0 signature v3 (TC3-HMAC-SHA256). | +| **keyId** | `string` | ✅ | The SecretId (not a secret); sent in the `Credential` of every signature. | +| **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the SecretKey every request's signing key is derived from. Never an inline secret. | + ### Nested Shape: `DeclarativeConnectorEntry.actions[number]` | Property | Type | Required | Description | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/shared.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/shared.md index 7aa70ec0af6..4df8380d968 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/shared.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/shared.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `shared/` | 24 | +| `shared/` | 25 | diff --git a/packages/connectors/connector-rest/src/connector-rest-plugin.test.ts b/packages/connectors/connector-rest/src/connector-rest-plugin.test.ts index 96a2fc4ff04..f5ddf6dcb60 100644 --- a/packages/connectors/connector-rest/src/connector-rest-plugin.test.ts +++ b/packages/connectors/connector-rest/src/connector-rest-plugin.test.ts @@ -270,4 +270,55 @@ describe('ConnectorRestPlugin — a declarative `signed` instance (ADR-0143 D2.3 await kernel.shutdown(); }); + + it('signs a tencentcloud-tc3-hmac-sha256 instance the same way, with the service named by its host', async () => { + const KEY = 'dummy-tencent-secret-key-26710'; + const calls: Array<{ url: string; headers: Record }> = []; + const impl = (async (url: string, init: RequestInit) => { + calls.push({ url, headers: init.headers as Record }); + return new Response('{"Response":{"RequestId":"r-1"}}', { status: 200, headers: { 'content-type': 'application/json' } }); + }) as unknown as typeof fetch; + const declared = [{ + name: 'tencent_cvm', + label: 'Tencent CVM', + type: 'api', + provider: 'rest', + providerConfig: { baseUrl: 'https://cvm.tencentcloudapi.com', defaultHeaders: { 'X-TC-Version': '2017-03-12', 'X-TC-Region': 'ap-guangzhou' } }, + auth: { type: 'signed', scheme: 'tencentcloud-tc3-hmac-sha256', keyId: 'test-secret-id', credentialRef: 'TENCENT_SECRET_26710' }, + }]; + const kernel = new LiteKernel(); + kernel.use(new AutomationServicePlugin({ credentialResolver: (r) => (r === 'TENCENT_SECRET_26710' ? KEY : undefined) })); + kernel.use(new ConnectorRestPlugin({ fetchImpl: impl })); + kernel.use({ + name: 'test.metadata', + type: 'standard', + version: '1.0.0', + dependencies: [], + async init(ctx: any) { + ctx.registerService('objectql', { registry: { listItems: (t: string) => (t === 'connector' ? declared : []) } }); + }, + async start() {}, + } as never); + await kernel.bootstrap(); + const engine = kernel.getService('automation'); + expect(engine.getConnectorDegradedReason('tencent_cvm')).toBeUndefined(); + + const request = engine.resolveConnectorAction('tencent_cvm', 'request')!; + const answered = await request({ method: 'POST', path: '/', body: { Limit: 1 }, headers: { 'X-TC-Action': 'DescribeInstances' } }, {} as never); + expect(answered).toEqual({ status: 200, ok: true, body: { Response: { RequestId: 'r-1' } } }); + + expect(calls).toHaveLength(1); + const headers = calls[0]!.headers; + expect(headers.authorization).toMatch( + /^TC3-HMAC-SHA256 Credential=test-secret-id\/\d{4}-\d{2}-\d{2}\/cvm\/tc3_request, SignedHeaders=content-type;host, Signature=[0-9a-f]{64}$/, + ); + expect(headers['x-tc-timestamp']).toMatch(/^\d{10}$/); + expect(headers['content-type']).toBe('application/json'); + expect(headers['x-tc-action']).toBe('DescribeInstances'); + + expect(JSON.stringify(calls)).not.toContain(KEY); + expect(JSON.stringify(engine.getConnectorDescriptors())).not.toContain(KEY); + + await kernel.shutdown(); + }); }); diff --git a/packages/services/service-automation/src/request-signing/index.ts b/packages/services/service-automation/src/request-signing/index.ts index 78edb7665b8..9d43f0764c1 100644 --- a/packages/services/service-automation/src/request-signing/index.ts +++ b/packages/services/service-automation/src/request-signing/index.ts @@ -2,6 +2,7 @@ import type { ConnectorInstanceSignedAuth, ConnectorRequestSigner } from '@objectstack/spec/integration'; import { createAliyunAcs3Signer } from './aliyun-acs3-hmac-sha256.js'; +import { createTencentCloudTc3Signer } from './tencentcloud-tc3-hmac-sha256.js'; import type { RequestSigningClock } from './clock.js'; export type { RequestSigningClock } from './clock.js'; @@ -31,9 +32,14 @@ export function createConnectorRequestSigner( switch (auth.scheme) { case 'aliyun-acs3-hmac-sha256': return createAliyunAcs3Signer(connector, auth.keyId, key, clock); + case 'tencentcloud-tc3-hmac-sha256': + return createTencentCloudTc3Signer(connector, auth.keyId, key, clock); default: { - const unsigned: never = auth.scheme; - throw new Error(`connector instance '${connector}': no signer implements scheme '${String(unsigned)}'`); + // Every member handled leaves `auth` itself `never` here. + const unsigned: never = auth; + throw new Error( + `connector instance '${connector}': no signer implements scheme '${String((unsigned as { scheme?: unknown }).scheme)}'`, + ); } } } diff --git a/packages/services/service-automation/src/request-signing/tencentcloud-tc3-hmac-sha256.test.ts b/packages/services/service-automation/src/request-signing/tencentcloud-tc3-hmac-sha256.test.ts new file mode 100644 index 00000000000..4d1f2cc18cd --- /dev/null +++ b/packages/services/service-automation/src/request-signing/tencentcloud-tc3-hmac-sha256.test.ts @@ -0,0 +1,149 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0143 D2.3 vector rule: Tencent Cloud's own worked example of its API 3.0 +// signature masks the SecretKey, so it cannot be reproduced. The vectors come +// from the vendor's official SDK test suite instead: `tencentcloud-sdk-nodejs` +// release 4.1.336, `test/common/sign.test.ts`, describe "sign3" — the POST +// (`application/json`) and GET (`application/x-www-form-urlencoded`) cases, +// with that suite's own placeholder key pair (`test-secret-id` / +// `test-secret-key`), timestamp 1640995200 and service `cvm`. Its multipart +// case is not carried: a signed request's body is text here +// (`connectorFetchOptions` refuses any other), so this signer has no multipart +// path to pin. + +import { describe, it, expect } from 'vitest'; +import { computeTencentCloudTc3Signature, createTencentCloudTc3Signer } from './tencentcloud-tc3-hmac-sha256.js'; +import { createConnectorRequestSigner } from './index.js'; + +const SDK_SECRET_ID = 'test-secret-id'; +const SDK_SECRET_KEY = 'test-secret-key'; +const SDK_TIMESTAMP = 1640995200; +const SDK_URL = 'https://cvm.tencentcloudapi.com'; +// The suite's `payload: { Action: "DescribeInstances", Version: "2017-03-12" }`, +// as the SDK serialises it for the body it hashes. +const SDK_BODY = '{"Action":"DescribeInstances","Version":"2017-03-12"}'; +// The suite sends this header on both cases; the scheme does not sign it. +const SDK_TRACE_HEADER = { 'X-TC-TraceId': 'test-trace-id' }; + +const SDK_POST_AUTHORIZATION = 'TC3-HMAC-SHA256 Credential=test-secret-id/2022-01-01/cvm/tc3_request, ' + + 'SignedHeaders=content-type;host, Signature=96473b50fe407833672a5d6d067238d88bb44c8abcc39ef83baa3132afbfadd3'; +const SDK_GET_AUTHORIZATION = 'TC3-HMAC-SHA256 Credential=test-secret-id/2022-01-01/cvm/tc3_request, ' + + 'SignedHeaders=content-type;host, Signature=39ce411ac32014b7d814118b33f82d3445be566f67e1f4b369ccdc3791ce9a13'; + +const postRequest = { + method: 'POST', + url: SDK_URL, + headers: { ...SDK_TRACE_HEADER, 'Content-Type': 'application/json' }, + body: SDK_BODY, +}; +// The SDK hashes its payload on a GET too, so the vector's GET carries the body. +const getRequest = { + method: 'GET', + url: SDK_URL, + headers: { ...SDK_TRACE_HEADER, 'Content-Type': 'application/x-www-form-urlencoded' }, + body: SDK_BODY, +}; + +describe('tencentcloud-tc3-hmac-sha256 — the tencentcloud-sdk-nodejs 4.1.336 sign3 vectors, byte for byte', () => { + it('reproduces the POST (application/json) case', () => { + const computed = computeTencentCloudTc3Signature(postRequest, SDK_SECRET_ID, SDK_SECRET_KEY, SDK_TIMESTAMP); + expect(computed.headers.authorization).toBe(SDK_POST_AUTHORIZATION); + expect(computed.headers['x-tc-timestamp']).toBe('1640995200'); + }); + + it('reproduces the GET (application/x-www-form-urlencoded) case', () => { + const computed = computeTencentCloudTc3Signature(getRequest, SDK_SECRET_ID, SDK_SECRET_KEY, SDK_TIMESTAMP); + expect(computed.headers.authorization).toBe(SDK_GET_AUTHORIZATION); + }); + + it('signs the canonical request the vendor specifies: method, path, query, content-type and host, body hash', () => { + const computed = computeTencentCloudTc3Signature(postRequest, SDK_SECRET_ID, SDK_SECRET_KEY, SDK_TIMESTAMP); + const lines = computed.canonicalRequest.split('\n'); + expect(lines.slice(0, 7)).toEqual([ + 'POST', + '/', + '', + 'content-type:application/json', + 'host:cvm.tencentcloudapi.com', + '', + 'content-type;host', + ]); + expect(lines[7]).toMatch(/^[0-9a-f]{64}$/); + expect(computed.stringToSign.split('\n').slice(0, 3)).toEqual(['TC3-HMAC-SHA256', '1640995200', '2022-01-01/cvm/tc3_request']); + }); + + it('the bound signer answers the same bytes at the same instant', async () => { + const signer = createTencentCloudTc3Signer('tencent_cvm', SDK_SECRET_ID, SDK_SECRET_KEY, { + now: () => new Date(SDK_TIMESTAMP * 1000 + 999), + }); + const { headers } = await signer.sign(postRequest); + expect(headers.authorization).toBe(SDK_POST_AUTHORIZATION); + expect(headers['x-tc-timestamp']).toBe('1640995200'); + }); + + it('a wrong key does not produce the vendor signature', () => { + const computed = computeTencentCloudTc3Signature(postRequest, SDK_SECRET_ID, 'not-the-test-secret-key', SDK_TIMESTAMP); + expect(computed.headers.authorization).not.toBe(SDK_POST_AUTHORIZATION); + expect(computed.headers.authorization).toMatch(/Signature=[0-9a-f]{64}$/); + }); +}); + +describe('tencentcloud-tc3-hmac-sha256 — beyond the vectors', () => { + it('leaves the unsigned API headers (X-TC-Action / -Version / -Region) out of the signature, as the SDK does', () => { + const withApiHeaders = { + ...postRequest, + headers: { ...postRequest.headers, 'X-TC-Action': 'DescribeInstances', 'X-TC-Version': '2017-03-12', 'X-TC-Region': 'ap-guangzhou' }, + }; + const computed = computeTencentCloudTc3Signature(withApiHeaders, SDK_SECRET_ID, SDK_SECRET_KEY, SDK_TIMESTAMP); + expect(computed.headers.authorization).toBe(SDK_POST_AUTHORIZATION); + }); + + it('signs and sends application/x-www-form-urlencoded when the request names no content type', () => { + const { 'Content-Type': _ct, ...noContentType } = getRequest.headers; + const computed = computeTencentCloudTc3Signature({ ...getRequest, headers: noContentType }, SDK_SECRET_ID, SDK_SECRET_KEY, SDK_TIMESTAMP); + expect(computed.headers['content-type']).toBe('application/x-www-form-urlencoded'); + expect(computed.headers.authorization).toBe(SDK_GET_AUTHORIZATION); + // CONTROL: a request that names its type is not given another. + expect('content-type' in computeTencentCloudTc3Signature(postRequest, SDK_SECRET_ID, SDK_SECRET_KEY, SDK_TIMESTAMP).headers).toBe(false); + }); + + it('names the service by the first label of the host, regional endpoints included', () => { + const computed = computeTencentCloudTc3Signature( + { ...postRequest, url: 'https://cvm.ap-guangzhou.tencentcloudapi.com' }, SDK_SECRET_ID, SDK_SECRET_KEY, SDK_TIMESTAMP, + ); + expect(computed.headers.authorization).toContain('Credential=test-secret-id/2022-01-01/cvm/tc3_request'); + expect(computed.canonicalRequest).toContain('host:cvm.ap-guangzhou.tencentcloudapi.com\n'); + }); + + it('signs the query exactly as it is sent', () => { + const computed = computeTencentCloudTc3Signature( + { method: 'GET', url: 'https://cvm.tencentcloudapi.com/?Limit=1&Offset=0', headers: {} }, SDK_SECRET_ID, SDK_SECRET_KEY, SDK_TIMESTAMP, + ); + expect(computed.canonicalRequest.split('\n').slice(0, 3)).toEqual(['GET', '/', 'Limit=1&Offset=0']); + }); + + it('chooses the current instant on every request, so a retry is signed afresh', async () => { + let t = SDK_TIMESTAMP * 1000; + const signer = createTencentCloudTc3Signer('tencent_cvm', SDK_SECRET_ID, SDK_SECRET_KEY, { now: () => new Date((t += 1000)) }); + const first = await signer.sign(postRequest); + const second = await signer.sign(postRequest); + expect(first.headers['x-tc-timestamp']).not.toBe(second.headers['x-tc-timestamp']); + expect(first.headers.authorization).not.toBe(second.headers.authorization); + }); +}); + +describe('createConnectorRequestSigner — tencentcloud-tc3-hmac-sha256', () => { + const auth = { type: 'signed', scheme: 'tencentcloud-tc3-hmac-sha256', keyId: SDK_SECRET_ID, credentialRef: 'TENCENT_SECRET' } as const; + + it('binds the scheme the instance names to its key', async () => { + const signer = createConnectorRequestSigner('tencent_cvm', auth, SDK_SECRET_KEY, { now: () => new Date(SDK_TIMESTAMP * 1000) }); + expect([signer.connector, signer.scheme]).toEqual(['tencent_cvm', 'tencentcloud-tc3-hmac-sha256']); + expect((await signer.sign(postRequest)).headers.authorization).toBe(SDK_POST_AUTHORIZATION); + }); + + it('never carries the key in what it hands back', async () => { + const signer = createConnectorRequestSigner('tencent_cvm', auth, SDK_SECRET_KEY); + const signature = await signer.sign(postRequest); + expect(JSON.stringify({ signer, signature })).not.toContain(SDK_SECRET_KEY); + }); +}); diff --git a/packages/services/service-automation/src/request-signing/tencentcloud-tc3-hmac-sha256.ts b/packages/services/service-automation/src/request-signing/tencentcloud-tc3-hmac-sha256.ts new file mode 100644 index 00000000000..d3e71d1cb90 --- /dev/null +++ b/packages/services/service-automation/src/request-signing/tencentcloud-tc3-hmac-sha256.ts @@ -0,0 +1,132 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { createHash, createHmac } from 'node:crypto'; +import type { + ConnectorRequestSignature, + ConnectorRequestSigner, + ConnectorSignableRequest, +} from '@objectstack/spec/integration'; +import type { RequestSigningClock } from './clock.js'; + +/** + * `tencentcloud-tc3-hmac-sha256` — Tencent Cloud's API 3.0 request signature + * ("signature method v3", ADR-0143 D2.3, Q4). The vendor's own worked example + * masks its SecretKey, so it cannot be reproduced; the vectors come from the + * official Node.js SDK's test suite instead, and + * `tencentcloud-tc3-hmac-sha256.test.ts` names that SDK and its release. + * + * Every request carries: + * - `X-TC-Timestamp` (Unix seconds), chosen here, per attempt — the vendor + * refuses a timestamp outside its window, so a retry is signed afresh; + * - `Authorization: TC3-HMAC-SHA256 Credential=/// + * tc3_request, SignedHeaders=content-type;host, Signature=`. + * + * The canonical request is the upper-case method, the path, the query exactly + * as sent, the `content-type` and `host` headers, their names, and the hex + * SHA-256 of the body. The string to sign is `TC3-HMAC-SHA256`, the timestamp, + * the credential scope `//tc3_request` and the hashed canonical + * request; the HMAC key is derived `HMAC("TC3" + SecretKey, date)` → + * `service` → `"tc3_request"`, the date being the timestamp's UTC day. + * + * `` is the first label of the endpoint host + * (`cvm.tencentcloudapi.com` and `cvm.ap-guangzhou.tencentcloudapi.com` → + * `cvm`), which is how the vendor names it. The request names its API with the + * `X-TC-Action`, `X-TC-Version` and `X-TC-Region` headers the caller sends; + * the scheme does not sign them. A request without a content type is signed + * and sent with `application/x-www-form-urlencoded`, the type the vendor's SDK + * sends on a `GET`, so the header the signature covers is the header the + * upstream reads. + * + * ⛔ The SecretKey lives only in the closure of the signer this module + * returns: no error, log line or returned value carries it. + */ + +export const TENCENTCLOUD_TC3_ALGORITHM = 'TC3-HMAC-SHA256'; + +/** What a request without a `Content-Type` is signed and sent with. */ +const DEFAULT_CONTENT_TYPE = 'application/x-www-form-urlencoded'; + +function sha256Hex(text: string): string { + return createHash('sha256').update(text, 'utf8').digest('hex'); +} + +function hmac(key: string | Buffer, message: string): Buffer { + return createHmac('sha256', key).update(message, 'utf8').digest(); +} + +/** `YYYY-MM-DD`, the UTC day of a Unix timestamp in seconds — the credential scope's date. */ +function utcDate(timestamp: number): string { + return new Date(timestamp * 1000).toISOString().slice(0, 10); +} + +/** Every intermediate of one signature — what the SDK-vector test pins, step by step. */ +export interface TencentCloudTc3Computation { + readonly canonicalRequest: string; + readonly stringToSign: string; + readonly signature: string; + /** Headers the signer adds: `authorization`, `x-tc-timestamp`, and `content-type` when the request had none. */ + readonly headers: Readonly>; +} + +/** + * Compute the TC3-HMAC-SHA256 signature of `request` at `timestamp` (Unix + * seconds). Pure: the same inputs give the same bytes, which is what lets the + * SDK's vectors pin it. + */ +export function computeTencentCloudTc3Signature( + request: ConnectorSignableRequest, + keyId: string, + secret: string, + timestamp: number, +): TencentCloudTc3Computation { + const url = new URL(request.url); + const service = url.hostname.split('.')[0] ?? ''; + let contentType: string | undefined; + for (const [name, value] of Object.entries(request.headers)) { + if (name.toLowerCase() === 'content-type') contentType = value; + } + const signedContentType = contentType ?? DEFAULT_CONTENT_TYPE; + const signedHeaders = 'content-type;host'; + const canonicalRequest = [ + request.method.toUpperCase(), + url.pathname || '/', + url.search.slice(1), + `content-type:${signedContentType}\nhost:${url.hostname}\n`, + signedHeaders, + sha256Hex(request.body ?? ''), + ].join('\n'); + const date = utcDate(timestamp); + const scope = `${date}/${service}/tc3_request`; + const stringToSign = `${TENCENTCLOUD_TC3_ALGORITHM}\n${timestamp}\n${scope}\n${sha256Hex(canonicalRequest)}`; + const signingKey = hmac(hmac(hmac(`TC3${secret}`, date), service), 'tc3_request'); + const signature = createHmac('sha256', signingKey).update(stringToSign, 'utf8').digest('hex'); + + return { + canonicalRequest, + stringToSign, + signature, + headers: { + ...(contentType === undefined ? { 'content-type': DEFAULT_CONTENT_TYPE } : {}), + 'x-tc-timestamp': String(timestamp), + authorization: `${TENCENTCLOUD_TC3_ALGORITHM} Credential=${keyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`, + }, + }; +} + +/** Bind a TC3-HMAC-SHA256 signer to one instance's SecretId and SecretKey. */ +export function createTencentCloudTc3Signer( + connector: string, + keyId: string, + secret: string, + clock: RequestSigningClock = {}, +): ConnectorRequestSigner { + const now = clock.now ?? (() => new Date()); + return { + connector, + scheme: 'tencentcloud-tc3-hmac-sha256', + async sign(request): Promise { + const timestamp = Math.floor(now().getTime() / 1000); + return { headers: computeTencentCloudTc3Signature(request, keyId, secret, timestamp).headers }; + }, + }; +} diff --git a/packages/spec/api-surface/integration.json b/packages/spec/api-surface/integration.json index e2ced88683c..84f2bbadd8b 100644 --- a/packages/spec/api-surface/integration.json +++ b/packages/spec/api-surface/integration.json @@ -42,6 +42,8 @@ "ConnectorInstanceSignedAliyunAcs3AuthSchema (const)", "ConnectorInstanceSignedAuth (type)", "ConnectorInstanceSignedAuthSchema (const)", + "ConnectorInstanceSignedTencentCloudTc3Auth (type)", + "ConnectorInstanceSignedTencentCloudTc3AuthSchema (const)", "ConnectorMaterialization (interface)", "ConnectorMaterializationHandler (type)", "ConnectorOAuth2Grant (type)", diff --git a/packages/spec/authorable-surface/integration.json b/packages/spec/authorable-surface/integration.json index a7d868e4eb8..1982bad25ff 100644 --- a/packages/spec/authorable-surface/integration.json +++ b/packages/spec/authorable-surface/integration.json @@ -71,6 +71,10 @@ "integration/ConnectorInstanceSignedAliyunAcs3Auth:keyId", "integration/ConnectorInstanceSignedAliyunAcs3Auth:scheme", "integration/ConnectorInstanceSignedAliyunAcs3Auth:type", + "integration/ConnectorInstanceSignedTencentCloudTc3Auth:credentialRef", + "integration/ConnectorInstanceSignedTencentCloudTc3Auth:keyId", + "integration/ConnectorInstanceSignedTencentCloudTc3Auth:scheme", + "integration/ConnectorInstanceSignedTencentCloudTc3Auth:type", "integration/DeclarativeConnectorEntry:_lock", "integration/DeclarativeConnectorEntry:_lockDocsUrl", "integration/DeclarativeConnectorEntry:_lockReason", diff --git a/packages/spec/declaration-map/integration.json b/packages/spec/declaration-map/integration.json index 46b092a4cc4..5b532f76a2a 100644 --- a/packages/spec/declaration-map/integration.json +++ b/packages/spec/declaration-map/integration.json @@ -31,6 +31,8 @@ "ConnectorInstanceSignedAliyunAcs3AuthSchema": "integration/ConnectorInstanceSignedAliyunAcs3Auth", "ConnectorInstanceSignedAuth": "integration/ConnectorInstanceSignedAuth", "ConnectorInstanceSignedAuthSchema": "integration/ConnectorInstanceSignedAuth", + "ConnectorInstanceSignedTencentCloudTc3Auth": "integration/ConnectorInstanceSignedTencentCloudTc3Auth", + "ConnectorInstanceSignedTencentCloudTc3AuthSchema": "integration/ConnectorInstanceSignedTencentCloudTc3Auth", "ConnectorOAuth2Grant": "integration/ConnectorOAuth2Grant", "ConnectorOAuth2GrantSchema": "integration/ConnectorOAuth2Grant", "ConnectorRetryStrategy": "integration/ConnectorRetryStrategy", diff --git a/packages/spec/export-origins/integration.json b/packages/spec/export-origins/integration.json index 9f3484e855d..8619626c825 100644 --- a/packages/spec/export-origins/integration.json +++ b/packages/spec/export-origins/integration.json @@ -42,6 +42,8 @@ "ConnectorInstanceSignedAliyunAcs3AuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceSignedAliyunAcs3AuthSchema (const)", "ConnectorInstanceSignedAuth": "src/shared/connector-auth.zod.ts#ConnectorInstanceSignedAuth (type)", "ConnectorInstanceSignedAuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceSignedAuthSchema (const)", + "ConnectorInstanceSignedTencentCloudTc3Auth": "src/shared/connector-auth.zod.ts#ConnectorInstanceSignedTencentCloudTc3Auth (type)", + "ConnectorInstanceSignedTencentCloudTc3AuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceSignedTencentCloudTc3AuthSchema (const)", "ConnectorMaterialization": "src/integration/connector-provider.ts#ConnectorMaterialization (interface)", "ConnectorMaterializationHandler": "src/integration/connector-provider.ts#ConnectorMaterializationHandler (type)", "ConnectorOAuth2Grant": "src/shared/connector-auth.zod.ts#ConnectorOAuth2Grant (type)", diff --git a/packages/spec/json-schema.manifest/integration.json b/packages/spec/json-schema.manifest/integration.json index 8a9725be706..f8c1666d258 100644 --- a/packages/spec/json-schema.manifest/integration.json +++ b/packages/spec/json-schema.manifest/integration.json @@ -17,6 +17,7 @@ "integration/ConnectorInstanceOAuth2ClientCredentialsAuth", "integration/ConnectorInstanceSignedAliyunAcs3Auth", "integration/ConnectorInstanceSignedAuth", + "integration/ConnectorInstanceSignedTencentCloudTc3Auth", "integration/ConnectorOAuth2Grant", "integration/ConnectorRetryStrategy", "integration/ConnectorSigningScheme", diff --git a/packages/spec/src/integration/connector-auth-signed.test.ts b/packages/spec/src/integration/connector-auth-signed.test.ts index 604133afe6b..d392fa80d61 100644 --- a/packages/spec/src/integration/connector-auth-signed.test.ts +++ b/packages/spec/src/integration/connector-auth-signed.test.ts @@ -31,6 +31,13 @@ const aliyun = { credentialRef: 'ALIYUN_ACCESS_KEY_SECRET', }; +const tencent = { + type: 'signed', + scheme: 'tencentcloud-tc3-hmac-sha256', + keyId: 'test-secret-id', + credentialRef: 'TENCENT_SECRET_KEY', +}; + /** The issues of a refused parse, as `code @ path` — what each refusal pins. */ function refused(input: unknown) { const result = DeclarativeConnectorEntrySchema.safeParse(input); @@ -47,12 +54,20 @@ describe('ADR-0143 D2.3 — `signed` instance auth', () => { expect(DeclarativeConnectorEntrySchema.parse(entry(aliyun, openapi)).auth).toEqual(aliyun); }); + it('CONTROL: accepts tencentcloud-tc3-hmac-sha256 with a SecretId and the SecretKey as a reference, on rest and openapi', () => { + const cvm = { providerConfig: { baseUrl: 'https://cvm.tencentcloudapi.com' } }; + expect(DeclarativeConnectorEntrySchema.parse(entry(tencent, cvm)).auth).toEqual(tencent); + const openapi = { provider: 'openapi', providerConfig: { spec: './cvm-openapi.json' } }; + expect(DeclarativeConnectorEntrySchema.parse(entry(tencent, openapi)).auth).toEqual(tencent); + }); + it('refuses an unknown scheme at `auth.scheme`, naming the scheme it got and the ones that ship', () => { for (const scheme of ['aws-sigv4', 'wechatpay-rsa2048', 'hmac-sha256-generic', 'alipay-rsa2']) { const issues = refused(entry({ ...aliyun, scheme })); expect(issues.map((i) => `${i.code} @ ${i.path.join('.')}`)).toEqual(['invalid_union @ auth.scheme']); expect(issues[0]!.message).toContain(`"${scheme}"`); expect(issues[0]!.message).toContain("'aliyun-acs3-hmac-sha256'"); + expect(issues[0]!.message).toContain("'tencentcloud-tc3-hmac-sha256'"); } }); @@ -63,13 +78,15 @@ describe('ADR-0143 D2.3 — `signed` instance auth', () => { expect(issues[0]!.message).toContain('needs a `scheme`'); }); - it('refuses each inline-secret spelling by name, pointing at credentialRef', () => { - for (const key of ['secret', 'privateKey', 'secretKey', 'accessKeySecret']) { - const issues = refused(entry({ ...aliyun, [key]: 'inline' })); - expect(issues.map((i) => [i.code, i.path.join('.'), (i as { keys?: string[] }).keys])).toEqual([ - ['unrecognized_keys', 'auth', [key]], - ]); - expect(issues[0]!.message).toContain('credentialRef'); + it('refuses each inline-secret spelling by name, pointing at credentialRef, on every scheme', () => { + for (const shape of [aliyun, tencent]) { + for (const key of ['secret', 'privateKey', 'secretKey', 'accessKeySecret']) { + const issues = refused(entry({ ...shape, [key]: 'inline' })); + expect(issues.map((i) => [i.code, i.path.join('.'), (i as { keys?: string[] }).keys])).toEqual([ + ['unrecognized_keys', 'auth', [key]], + ]); + expect(issues[0]!.message).toContain('credentialRef'); + } } }); @@ -88,6 +105,13 @@ describe('ADR-0143 D2.3 — `signed` instance auth', () => { expect(unknown.message).toContain('keyId'); }); + it("answers Tencent Cloud's own word for the key id with `keyId`", () => { + const { keyId, ...rest } = tencent; + const issues = refused(entry({ ...rest, secretId: keyId })); + const unknown = issues.find((i) => i.code === 'unrecognized_keys')!; + expect(unknown.message).toContain('keyId'); + }); + it('requires the key id and the secret reference', () => { const { keyId: _k, ...noKeyId } = aliyun; expect(located(entry(noKeyId))).toEqual(['invalid_type @ auth.keyId']); @@ -109,5 +133,6 @@ describe('ADR-0143 D2.3 — `signed` instance auth', () => { it('is a member of the closed instance-auth union', () => { expect(ConnectorInstanceAuthSchema.safeParse(aliyun).success).toBe(true); + expect(ConnectorInstanceAuthSchema.safeParse(tencent).success).toBe(true); }); }); diff --git a/packages/spec/src/integration/connector-fetch-policy.test.ts b/packages/spec/src/integration/connector-fetch-policy.test.ts index 27adb20588d..81d1682baad 100644 --- a/packages/spec/src/integration/connector-fetch-policy.test.ts +++ b/packages/spec/src/integration/connector-fetch-policy.test.ts @@ -118,7 +118,7 @@ describe('connectorFetchOptions — a signed instance is signed per attempt (ADR const seen: ConnectorSignableRequest[] = []; const signer: ConnectorRequestSigner = { connector: 'vendor', - scheme: 'test-scheme', + scheme: 'aliyun-acs3-hmac-sha256', async sign(request) { seen.push(request); return { headers: { Authorization: `Sig n${seen.length}`, 'X-Nonce': `n${seen.length}` } }; @@ -202,7 +202,7 @@ describe('readConnectorResponse', () => { }); it('CONTROL: a signer without response verification reads exactly as no signer', async () => { - const signer: ConnectorRequestSigner = { connector: 'v', scheme: 's', sign: async () => ({ headers: {} }) }; + const signer: ConnectorRequestSigner = { connector: 'v', scheme: 'aliyun-acs3-hmac-sha256', sign: async () => ({ headers: {} }) }; expect(await readConnectorResponse(json('{"a":1}'), signer)).toEqual({ status: 200, ok: true, body: { a: 1 } }); }); @@ -210,7 +210,7 @@ describe('readConnectorResponse', () => { const verified: string[] = []; const signer: ConnectorRequestSigner = { connector: 'v', - scheme: 's', + scheme: 'aliyun-acs3-hmac-sha256', sign: async () => ({ headers: {} }), async verifyResponse(response) { verified.push(`${response.status} ${response.headers.get('x-sig')} ${response.body}`); @@ -224,7 +224,7 @@ describe('readConnectorResponse', () => { it('a response that fails verification throws, and its body is never returned', async () => { const signer: ConnectorRequestSigner = { connector: 'v', - scheme: 's', + scheme: 'aliyun-acs3-hmac-sha256', sign: async () => ({ headers: {} }), async verifyResponse() { throw new Error('response signature does not verify'); }, }; diff --git a/packages/spec/src/integration/connector-provider.ts b/packages/spec/src/integration/connector-provider.ts index 01b3a68a442..412e8b27632 100644 --- a/packages/spec/src/integration/connector-provider.ts +++ b/packages/spec/src/integration/connector-provider.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { Connector, RetryConfigParsed } from './connector.zod'; -import type { ResolvedConnectorAuth } from '../shared/connector-auth.zod'; +import type { ConnectorSigningScheme, ResolvedConnectorAuth } from '../shared/connector-auth.zod'; /** * Connector **provider** contract (ADR-0097). @@ -170,7 +170,7 @@ export interface ConnectorRequestSigner { /** The connector instance this signer signs for. */ readonly connector: string; /** The scheme it implements — a member of `ConnectorSigningSchemeSchema`. */ - readonly scheme: string; + readonly scheme: ConnectorSigningScheme; /** * Sign one request. Called once per attempt, so a retried request carries a * fresh signature. Throws when the request cannot be signed (a body that is diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 0b8aa5ba2c5..7102d4bb17d 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -1288,6 +1288,7 @@ export { ConnectorAppCredentialVendorSchema, ConnectorInstanceSignedAuthSchema, ConnectorInstanceSignedAliyunAcs3AuthSchema, + ConnectorInstanceSignedTencentCloudTc3AuthSchema, ConnectorSigningSchemeSchema, } from '../shared/connector-auth.zod'; export type { @@ -1309,5 +1310,6 @@ export type { ConnectorAppCredentialVendor, ConnectorInstanceSignedAuth, ConnectorInstanceSignedAliyunAcs3Auth, + ConnectorInstanceSignedTencentCloudTc3Auth, ConnectorSigningScheme, } from '../shared/connector-auth.zod'; diff --git a/packages/spec/src/shared/connector-auth.zod.ts b/packages/spec/src/shared/connector-auth.zod.ts index 21e42b95801..c72c7a8d737 100644 --- a/packages/spec/src/shared/connector-auth.zod.ts +++ b/packages/spec/src/shared/connector-auth.zod.ts @@ -388,8 +388,11 @@ export type ConnectorInstanceAppCredentialAuth = z.input; /** @@ -431,6 +434,33 @@ export const ConnectorInstanceSignedAliyunAcs3AuthSchema = lazySchema(() => stri })); export type ConnectorInstanceSignedAliyunAcs3Auth = z.input; +/** + * `signed` with `scheme: 'tencentcloud-tc3-hmac-sha256'` — Tencent Cloud's API + * 3.0 signature v3 (ADR-0143 Q4). Every request carries an `Authorization: + * TC3-HMAC-SHA256 Credential=///tc3_request, + * SignedHeaders=content-type;host, Signature=…` header, an HMAC-SHA256 over the + * canonical request with a key derived from the SecretKey, the date and the + * service; the signer also sets the `X-TC-Timestamp` it signs with. The service + * is the first label of the endpoint host (`cvm.tencentcloudapi.com` → `cvm`), + * as the vendor names it, so nothing about it is authored. The request names + * its API with the `X-TC-Action`, `X-TC-Version` and `X-TC-Region` headers. + */ +export const ConnectorInstanceSignedTencentCloudTc3AuthSchema = lazySchema(() => strictObject({ + surface: "this `signed` connector auth (`scheme: 'tencentcloud-tc3-hmac-sha256'`)", + history: 'This shape is strict from birth — the `signed` instance kind had no earlier spelling, so an unknown key here was never accepted.', + guidance: SIGNED_GUIDANCE, + // Tencent Cloud's own word for the key id. + aliases: { secretId: 'keyId' }, +}, { + type: z.literal('signed'), + scheme: z.literal('tencentcloud-tc3-hmac-sha256').describe('Tencent Cloud API 3.0 signature v3 (TC3-HMAC-SHA256).'), + keyId: z.string().min(1).describe('The SecretId (not a secret); sent in the `Credential` of every signature.'), + credentialRef: z.string().min(1).describe( + 'Secrets-layer reference resolved to the SecretKey every request\'s signing key is derived from. Never an inline secret.', + ), +})); +export type ConnectorInstanceSignedTencentCloudTc3Auth = z.input; + /** * Per-request signing auth for a provider-bound instance (ADR-0143 D2.3): no * token exists — the platform signs every outgoing request with the key @@ -444,6 +474,7 @@ export type ConnectorInstanceSignedAliyunAcs3Auth = z.input z.discriminatedUnion('scheme', [ ConnectorInstanceSignedAliyunAcs3AuthSchema, + ConnectorInstanceSignedTencentCloudTc3AuthSchema, ], { // The refusal names the scheme it got and the ones that ship, rather than // zod's bare "invalid discriminator": a scheme is a vendor algorithm, and diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 65363d6ba76..560ea561e21 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -285,7 +285,7 @@ import type * as M194 from './automation/assignment-rule.zod.js'; import type * as M195 from './security/queue.zod.js'; // --------------------------------------------------------------------------- -// 804 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 805 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1171,6 +1171,7 @@ export type Iso_shared_connectorAuth__ConnectorInstanceOAuth2AuthSchema = Assert export type Iso_shared_connectorAuth__ConnectorInstanceOAuth2AuthorizationCodeAuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceOAuth2AuthorizationCodeAuthSchema > >>; export type Iso_shared_connectorAuth__ConnectorInstanceOAuth2ClientCredentialsAuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceOAuth2ClientCredentialsAuthSchema > >>; export type Iso_shared_connectorAuth__ConnectorInstanceSignedAliyunAcs3AuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceSignedAliyunAcs3AuthSchema > >>; +export type Iso_shared_connectorAuth__ConnectorInstanceSignedTencentCloudTc3AuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceSignedTencentCloudTc3AuthSchema > >>; export type Iso_shared_connectorAuth__ConnectorInstanceSignedAuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceSignedAuthSchema > >>; export type Iso_shared_connectorAuth__ConnectorOAuth2GrantSchema = Assert, z.infer< typeof M109.ConnectorOAuth2GrantSchema > >>; export type Iso_shared_connectorAuth__ConnectorSigningSchemeSchema = Assert, z.infer< typeof M109.ConnectorSigningSchemeSchema > >>; @@ -1738,7 +1739,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 804 isomorphic pins', () => { + it('still declares all 805 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2695,7 +2696,11 @@ describe('ADR-0122 type-alias convention', () => { // …ConnectorInstanceSignedAliyunAcs3AuthSchema, // …ConnectorSigningSchemeSchema) rather than given `…Parsed` synonyms. // +3 added. - expect(pins).toHaveLength(804); + // 804 -> 805 is #26710 (ADR-0143 S2): the `signed` union's + // `tencentcloud-tc3-hmac-sha256` shape, no default and no transform + // (Iso_shared_connectorAuth__ConnectorInstanceSignedTencentCloudTc3AuthSchema). + // +1 added. + expect(pins).toHaveLength(805); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either diff --git a/packages/spec/variant-docs.json b/packages/spec/variant-docs.json index 41022a8de63..afe005bd413 100644 --- a/packages/spec/variant-docs.json +++ b/packages/spec/variant-docs.json @@ -106,7 +106,7 @@ "note": "The `oauth2` member of connector-instance auth, one strict shape per grant (ADR-0143 D2.1): `authorizationUrl` exists only on authorization_code, and client_credentials requires `credentialRef`." }, { - "key": "scheme:aliyun-acs3-hmac-sha256", + "key": "scheme:aliyun-acs3-hmac-sha256|tencentcloud-tc3-hmac-sha256", "label": "connector instance signing scheme", "docs": [ "content/docs/automation/connectors.mdx"