From 29ded8193df64ba741443e0b6d428cc9f30895c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 07:20:07 +0000 Subject: [PATCH 1/8] feat(spec): alipay-v3 joins the signed connector auth schemes, naming the Alipay public key as vendorPublicKeyRef Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cxRP1AqTeP2PWAhwGK --- content/docs/references/index.mdx | 10 ++-- .../docs/references/integration/connector.mdx | 60 +++++++++++++++++-- .../shared.md | 2 +- packages/spec/api-surface/integration.json | 2 + .../spec/authorable-surface/integration.json | 5 ++ .../spec/declaration-map/integration.json | 2 + packages/spec/export-origins/integration.json | 2 + .../json-schema.manifest/integration.json | 1 + .../integration/connector-auth-signed.test.ts | 46 +++++++++++++- .../spec/src/integration/connector.zod.ts | 2 + .../spec/src/shared/connector-auth.zod.ts | 43 ++++++++++++- .../src/type-alias-convention.pin.test.ts | 11 +++- packages/spec/variant-docs.json | 2 +- 13 files changed, 170 insertions(+), 18 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 5e46543ac0e..c450df06baa 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 — 1570 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1571 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 | 22 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | +| [Integration Protocol](/docs/references/integration) | 1 | 23 | 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** | **1570** | 14 protocol modules | +| **Total** | **203** | **1571** | 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, 22 schemas** +**Source:** `packages/spec/src/integration/` · **Import:** `@objectstack/spec/integration` · **1 page, 23 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`, `ConnectorInstanceSignedTencentCloudTc3Auth`, `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`, `ConnectorInstanceSignedAlipayV3Auth`, `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 5bf97653293..dd91a8550ff 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, 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'; +import { ConnectorSchema, ConnectorActionSchema, ConnectorActionEffectSchema, ConnectorAppCredentialVendorSchema, ConnectorInstanceAPIKeyAuthSchema, ConnectorInstanceAppCredentialAuthSchema, ConnectorInstanceAuthSchema, ConnectorInstanceBasicAuthSchema, ConnectorInstanceBearerAuthSchema, ConnectorInstanceNoAuthSchema, ConnectorInstanceOAuth2AuthSchema, ConnectorInstanceOAuth2AuthorizationCodeAuthSchema, ConnectorInstanceOAuth2ClientCredentialsAuthSchema, ConnectorInstanceSignedAlipayV3AuthSchema, 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, ConnectorInstanceSignedAlipayV3Auth, ConnectorInstanceSignedAliyunAcs3Auth, ConnectorInstanceSignedAuth, ConnectorInstanceSignedTencentCloudTc3Auth, ConnectorOAuth2Grant, ConnectorRetryStrategy, ConnectorSigningScheme, ConnectorType, DeclarativeConnectorEntry, RetryConfig } from '@objectstack/spec/integration'; // Validate data const result = ConnectorSchema.parse(data); @@ -311,6 +311,16 @@ const result = ConnectorSchema.parse(data); | **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.auth[option 7][scheme='alipay-v3']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'signed'` | ✅ | | +| **scheme** | `'alipay-v3'` | ✅ | Alipay OpenAPI v3 signature (ALIPAY-SHA256withRSA); responses are verified. | +| **keyId** | `string` | ✅ | The Alipay app id (not a secret); sent as `app_id` in every signature. | +| **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the application private key (RSA) every request is signed with. Never an inline key. | +| **vendorPublicKeyRef** | `string` | ✅ | Secrets-layer reference resolved to the Alipay public key every response's signature is verified against. | + ### Nested Shape: `Connector.actions[number]` | Property | Type | Required | Description | @@ -490,7 +500,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: 'signed'; scheme: 'tencentcloud-tc3-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 } | { type: 'signed'; scheme: 'alipay-v3'; keyId: string; credentialRef: string; … }` --- @@ -612,6 +622,21 @@ This schema accepts one of the following structures: | **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret; declared together with `clientId` (a `client_credentials` client is confidential). Never an inline secret. | +--- + +## ConnectorInstanceSignedAlipayV3Auth + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'signed'` | ✅ | | +| **scheme** | `'alipay-v3'` | ✅ | Alipay OpenAPI v3 signature (ALIPAY-SHA256withRSA); responses are verified. | +| **keyId** | `string` | ✅ | The Alipay app id (not a secret); sent as `app_id` in every signature. | +| **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the application private key (RSA) every request is signed with. Never an inline key. | +| **vendorPublicKeyRef** | `string` | ✅ | Secrets-layer reference resolved to the Alipay public key every response's signature is verified against. | + + --- ## ConnectorInstanceSignedAliyunAcs3Auth @@ -664,6 +689,22 @@ This schema accepts one of the following structures: --- +#### Option 3 + +**Type:** `signed` + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'signed'` | ✅ | | +| **scheme** | `'alipay-v3'` | ✅ | Alipay OpenAPI v3 signature (ALIPAY-SHA256withRSA); responses are verified. | +| **keyId** | `string` | ✅ | The Alipay app id (not a secret); sent as `app_id` in every signature. | +| **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the application private key (RSA) every request is signed with. Never an inline key. | +| **vendorPublicKeyRef** | `string` | ✅ | Secrets-layer reference resolved to the Alipay public key every response's signature is verified against. | + +--- + --- @@ -709,12 +750,13 @@ Retry strategy ## ConnectorSigningScheme -Request-signing scheme: `aliyun-acs3-hmac-sha256` (Alibaba Cloud OpenAPI V3 signature) or `tencentcloud-tc3-hmac-sha256` (Tencent Cloud API 3.0 signature v3). +Request-signing scheme: `aliyun-acs3-hmac-sha256` (Alibaba Cloud OpenAPI V3 signature), `tencentcloud-tc3-hmac-sha256` (Tencent Cloud API 3.0 signature v3) or `alipay-v3` (Alipay OpenAPI v3, ALIPAY-SHA256withRSA, responses verified). ### Allowed Values * `aliyun-acs3-hmac-sha256` * `tencentcloud-tc3-hmac-sha256` +* `alipay-v3` --- @@ -872,6 +914,16 @@ Connector type | **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.auth[option 7][scheme='alipay-v3']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'signed'` | ✅ | | +| **scheme** | `'alipay-v3'` | ✅ | Alipay OpenAPI v3 signature (ALIPAY-SHA256withRSA); responses are verified. | +| **keyId** | `string` | ✅ | The Alipay app id (not a secret); sent as `app_id` in every signature. | +| **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the application private key (RSA) every request is signed with. Never an inline key. | +| **vendorPublicKeyRef** | `string` | ✅ | Secrets-layer reference resolved to the Alipay public key every response's signature is verified against. | + ### 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 4df8380d968..72490bbcf61 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/` | 25 | +| `shared/` | 26 | diff --git a/packages/spec/api-surface/integration.json b/packages/spec/api-surface/integration.json index 84f2bbadd8b..b86f299b946 100644 --- a/packages/spec/api-surface/integration.json +++ b/packages/spec/api-surface/integration.json @@ -38,6 +38,8 @@ "ConnectorInstanceOAuth2AuthorizationCodeAuthSchema (const)", "ConnectorInstanceOAuth2ClientCredentialsAuth (type)", "ConnectorInstanceOAuth2ClientCredentialsAuthSchema (const)", + "ConnectorInstanceSignedAlipayV3Auth (type)", + "ConnectorInstanceSignedAlipayV3AuthSchema (const)", "ConnectorInstanceSignedAliyunAcs3Auth (type)", "ConnectorInstanceSignedAliyunAcs3AuthSchema (const)", "ConnectorInstanceSignedAuth (type)", diff --git a/packages/spec/authorable-surface/integration.json b/packages/spec/authorable-surface/integration.json index 1982bad25ff..45061bb145a 100644 --- a/packages/spec/authorable-surface/integration.json +++ b/packages/spec/authorable-surface/integration.json @@ -67,6 +67,11 @@ "integration/ConnectorInstanceOAuth2ClientCredentialsAuth:scopes", "integration/ConnectorInstanceOAuth2ClientCredentialsAuth:tokenUrl", "integration/ConnectorInstanceOAuth2ClientCredentialsAuth:type", + "integration/ConnectorInstanceSignedAlipayV3Auth:credentialRef", + "integration/ConnectorInstanceSignedAlipayV3Auth:keyId", + "integration/ConnectorInstanceSignedAlipayV3Auth:scheme", + "integration/ConnectorInstanceSignedAlipayV3Auth:type", + "integration/ConnectorInstanceSignedAlipayV3Auth:vendorPublicKeyRef", "integration/ConnectorInstanceSignedAliyunAcs3Auth:credentialRef", "integration/ConnectorInstanceSignedAliyunAcs3Auth:keyId", "integration/ConnectorInstanceSignedAliyunAcs3Auth:scheme", diff --git a/packages/spec/declaration-map/integration.json b/packages/spec/declaration-map/integration.json index 5b532f76a2a..cd1c4c93b95 100644 --- a/packages/spec/declaration-map/integration.json +++ b/packages/spec/declaration-map/integration.json @@ -27,6 +27,8 @@ "ConnectorInstanceOAuth2AuthorizationCodeAuthSchema": "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth", "ConnectorInstanceOAuth2ClientCredentialsAuth": "integration/ConnectorInstanceOAuth2ClientCredentialsAuth", "ConnectorInstanceOAuth2ClientCredentialsAuthSchema": "integration/ConnectorInstanceOAuth2ClientCredentialsAuth", + "ConnectorInstanceSignedAlipayV3Auth": "integration/ConnectorInstanceSignedAlipayV3Auth", + "ConnectorInstanceSignedAlipayV3AuthSchema": "integration/ConnectorInstanceSignedAlipayV3Auth", "ConnectorInstanceSignedAliyunAcs3Auth": "integration/ConnectorInstanceSignedAliyunAcs3Auth", "ConnectorInstanceSignedAliyunAcs3AuthSchema": "integration/ConnectorInstanceSignedAliyunAcs3Auth", "ConnectorInstanceSignedAuth": "integration/ConnectorInstanceSignedAuth", diff --git a/packages/spec/export-origins/integration.json b/packages/spec/export-origins/integration.json index 8619626c825..d8eff019687 100644 --- a/packages/spec/export-origins/integration.json +++ b/packages/spec/export-origins/integration.json @@ -38,6 +38,8 @@ "ConnectorInstanceOAuth2AuthorizationCodeAuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceOAuth2AuthorizationCodeAuthSchema (const)", "ConnectorInstanceOAuth2ClientCredentialsAuth": "src/shared/connector-auth.zod.ts#ConnectorInstanceOAuth2ClientCredentialsAuth (type)", "ConnectorInstanceOAuth2ClientCredentialsAuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceOAuth2ClientCredentialsAuthSchema (const)", + "ConnectorInstanceSignedAlipayV3Auth": "src/shared/connector-auth.zod.ts#ConnectorInstanceSignedAlipayV3Auth (type)", + "ConnectorInstanceSignedAlipayV3AuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceSignedAlipayV3AuthSchema (const)", "ConnectorInstanceSignedAliyunAcs3Auth": "src/shared/connector-auth.zod.ts#ConnectorInstanceSignedAliyunAcs3Auth (type)", "ConnectorInstanceSignedAliyunAcs3AuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceSignedAliyunAcs3AuthSchema (const)", "ConnectorInstanceSignedAuth": "src/shared/connector-auth.zod.ts#ConnectorInstanceSignedAuth (type)", diff --git a/packages/spec/json-schema.manifest/integration.json b/packages/spec/json-schema.manifest/integration.json index f8c1666d258..3a3db623786 100644 --- a/packages/spec/json-schema.manifest/integration.json +++ b/packages/spec/json-schema.manifest/integration.json @@ -15,6 +15,7 @@ "integration/ConnectorInstanceOAuth2Auth", "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth", "integration/ConnectorInstanceOAuth2ClientCredentialsAuth", + "integration/ConnectorInstanceSignedAlipayV3Auth", "integration/ConnectorInstanceSignedAliyunAcs3Auth", "integration/ConnectorInstanceSignedAuth", "integration/ConnectorInstanceSignedTencentCloudTc3Auth", diff --git a/packages/spec/src/integration/connector-auth-signed.test.ts b/packages/spec/src/integration/connector-auth-signed.test.ts index d392fa80d61..8dbc690d6c7 100644 --- a/packages/spec/src/integration/connector-auth-signed.test.ts +++ b/packages/spec/src/integration/connector-auth-signed.test.ts @@ -38,6 +38,14 @@ const tencent = { credentialRef: 'TENCENT_SECRET_KEY', }; +const alipay = { + type: 'signed', + scheme: 'alipay-v3', + keyId: '2021000000000000', + credentialRef: 'ALIPAY_APP_PRIVATE_KEY', + vendorPublicKeyRef: 'ALIPAY_PUBLIC_KEY', +}; + /** The issues of a refused parse, as `code @ path` — what each refusal pins. */ function refused(input: unknown) { const result = DeclarativeConnectorEntrySchema.safeParse(input); @@ -61,6 +69,13 @@ describe('ADR-0143 D2.3 — `signed` instance auth', () => { expect(DeclarativeConnectorEntrySchema.parse(entry(tencent, openapi)).auth).toEqual(tencent); }); + it('CONTROL: accepts alipay-v3 with an app id, the private key and the Alipay public key as references, on rest and openapi', () => { + const gateway = { providerConfig: { baseUrl: 'https://openapi.alipay.com' } }; + expect(DeclarativeConnectorEntrySchema.parse(entry(alipay, gateway)).auth).toEqual(alipay); + const openapi = { provider: 'openapi', providerConfig: { spec: './alipay-v3-openapi.json' } }; + expect(DeclarativeConnectorEntrySchema.parse(entry(alipay, openapi)).auth).toEqual(alipay); + }); + 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 })); @@ -68,6 +83,7 @@ describe('ADR-0143 D2.3 — `signed` instance auth', () => { expect(issues[0]!.message).toContain(`"${scheme}"`); expect(issues[0]!.message).toContain("'aliyun-acs3-hmac-sha256'"); expect(issues[0]!.message).toContain("'tencentcloud-tc3-hmac-sha256'"); + expect(issues[0]!.message).toContain("'alipay-v3'"); } }); @@ -79,7 +95,7 @@ describe('ADR-0143 D2.3 — `signed` instance auth', () => { }); it('refuses each inline-secret spelling by name, pointing at credentialRef, on every scheme', () => { - for (const shape of [aliyun, tencent]) { + for (const shape of [aliyun, tencent, alipay]) { 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([ @@ -90,6 +106,19 @@ describe('ADR-0143 D2.3 — `signed` instance auth', () => { } }); + it("refuses Alipay's own spellings of its two keys: the private key by name, the public key towards its reference", () => { + const privateKey = refused(entry({ ...alipay, appPrivateKey: 'inline' })); + expect(privateKey.map((i) => [i.code, i.path.join('.'), (i as { keys?: string[] }).keys])).toEqual([ + ['unrecognized_keys', 'auth', ['appPrivateKey']], + ]); + expect(privateKey[0]!.message).toContain('credentialRef'); + const publicKey = refused(entry({ ...alipay, alipayPublicKey: 'inline' })); + expect(publicKey.map((i) => [i.code, i.path.join('.'), (i as { keys?: string[] }).keys])).toEqual([ + ['unrecognized_keys', 'auth', ['alipayPublicKey']], + ]); + expect(publicKey[0]!.message).toContain('vendorPublicKeyRef'); + }); + it('refuses an authored signature: the signer computes one per request', () => { const issues = refused(entry({ ...aliyun, signature: 'abc' })); expect(issues.map((i) => [i.code, i.path.join('.'), (i as { keys?: string[] }).keys])).toEqual([ @@ -112,6 +141,20 @@ describe('ADR-0143 D2.3 — `signed` instance auth', () => { expect(unknown.message).toContain('keyId'); }); + it("answers Alipay's own word for the key id with `keyId`", () => { + const { keyId, ...rest } = alipay; + const issues = refused(entry({ ...rest, appId: keyId })); + const unknown = issues.find((i) => i.code === 'unrecognized_keys')!; + expect(unknown.message).toContain('keyId'); + }); + + it('alipay-v3 requires the Alipay public key reference: its responses are always verified', () => { + const { vendorPublicKeyRef: _v, ...noPublicKey } = alipay; + expect(located(entry(noPublicKey))).toEqual(['invalid_type @ auth.vendorPublicKeyRef']); + // CONTROL: the schemes whose vendors do not sign responses take no such key. + expect(located(entry({ ...aliyun, vendorPublicKeyRef: 'ALIPAY_PUBLIC_KEY' }))).toEqual(['unrecognized_keys @ auth']); + }); + it('requires the key id and the secret reference', () => { const { keyId: _k, ...noKeyId } = aliyun; expect(located(entry(noKeyId))).toEqual(['invalid_type @ auth.keyId']); @@ -134,5 +177,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); + expect(ConnectorInstanceAuthSchema.safeParse(alipay).success).toBe(true); }); }); diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 7102d4bb17d..ca715a6aa48 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -1289,6 +1289,7 @@ export { ConnectorInstanceSignedAuthSchema, ConnectorInstanceSignedAliyunAcs3AuthSchema, ConnectorInstanceSignedTencentCloudTc3AuthSchema, + ConnectorInstanceSignedAlipayV3AuthSchema, ConnectorSigningSchemeSchema, } from '../shared/connector-auth.zod'; export type { @@ -1311,5 +1312,6 @@ export type { ConnectorInstanceSignedAuth, ConnectorInstanceSignedAliyunAcs3Auth, ConnectorInstanceSignedTencentCloudTc3Auth, + ConnectorInstanceSignedAlipayV3Auth, 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 c72c7a8d737..362de7a8ef6 100644 --- a/packages/spec/src/shared/connector-auth.zod.ts +++ b/packages/spec/src/shared/connector-auth.zod.ts @@ -388,10 +388,11 @@ export type ConnectorInstanceAppCredentialAuth = z.input; @@ -461,6 +462,41 @@ export const ConnectorInstanceSignedTencentCloudTc3AuthSchema = lazySchema(() => })); export type ConnectorInstanceSignedTencentCloudTc3Auth = z.input; +/** + * `signed` with `scheme: 'alipay-v3'` — Alipay's OpenAPI v3 signature (ADR-0143 + * Q4). Every request carries an `Authorization: ALIPAY-SHA256withRSA + * app_id=…,nonce=…,timestamp=…,sign=…` header, an RSA SHA-256 signature with + * the application private key over the auth string, the method, the path and + * query, and the body. The vendor signs its responses too, and this scheme + * verifies them (Q4 condition 2): every response is checked against the + * Alipay public key `vendorPublicKeyRef` names before its body is read. + * Public-key mode only; Alipay's certificate mode is not this shape. + */ +export const ConnectorInstanceSignedAlipayV3AuthSchema = lazySchema(() => strictObject({ + surface: "this `signed` connector auth (`scheme: 'alipay-v3'`)", + 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, + appPrivateKey: inlineSecretGuidance('signed', 'application private key'), + alipayPublicKey: 'Not authorable on `auth: { type: \'signed\', scheme: \'alipay-v3\' }`. The Alipay public key is ' + + 'replaced when the app\'s keys are, so it lives in the secrets layer beside the private key: put it there and ' + + 'name it with `vendorPublicKeyRef`.', + }, + // Alipay's own word for the key id. + aliases: { appId: 'keyId' }, +}, { + type: z.literal('signed'), + scheme: z.literal('alipay-v3').describe('Alipay OpenAPI v3 signature (ALIPAY-SHA256withRSA); responses are verified.'), + keyId: z.string().min(1).describe('The Alipay app id (not a secret); sent as `app_id` in every signature.'), + credentialRef: z.string().min(1).describe( + 'Secrets-layer reference resolved to the application private key (RSA) every request is signed with. Never an inline key.', + ), + vendorPublicKeyRef: z.string().min(1).describe( + 'Secrets-layer reference resolved to the Alipay public key every response\'s signature is verified against.', + ), +})); +export type ConnectorInstanceSignedAlipayV3Auth = 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 @@ -475,6 +511,7 @@ export type ConnectorInstanceSignedTencentCloudTc3Auth = z.input z.discriminatedUnion('scheme', [ ConnectorInstanceSignedAliyunAcs3AuthSchema, ConnectorInstanceSignedTencentCloudTc3AuthSchema, + ConnectorInstanceSignedAlipayV3AuthSchema, ], { // 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 560ea561e21..afa4aeac567 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'; // --------------------------------------------------------------------------- -// 805 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 806 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 @@ -1170,6 +1170,7 @@ export type Iso_shared_connectorAuth__ConnectorInstanceNoAuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceOAuth2AuthSchema > >>; 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__ConnectorInstanceSignedAlipayV3AuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceSignedAlipayV3AuthSchema > >>; 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 > >>; @@ -1739,7 +1740,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 805 isomorphic pins', () => { + it('still declares all 806 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 @@ -2700,7 +2701,11 @@ describe('ADR-0122 type-alias convention', () => { // `tencentcloud-tc3-hmac-sha256` shape, no default and no transform // (Iso_shared_connectorAuth__ConnectorInstanceSignedTencentCloudTc3AuthSchema). // +1 added. - expect(pins).toHaveLength(805); + // 805 -> 806 is #26710 (ADR-0143 S3): the `signed` union's `alipay-v3` + // shape, no default and no transform + // (Iso_shared_connectorAuth__ConnectorInstanceSignedAlipayV3AuthSchema). + // +1 added. + expect(pins).toHaveLength(806); // 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 afe005bd413..4ec7c340e61 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|tencentcloud-tc3-hmac-sha256", + "key": "scheme:alipay-v3|aliyun-acs3-hmac-sha256|tencentcloud-tc3-hmac-sha256", "label": "connector instance signing scheme", "docs": [ "content/docs/automation/connectors.mdx" From 22d14beb89b8b95115105c378671a02a3c59a8bf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 07:20:07 +0000 Subject: [PATCH 2/8] feat(service-automation): the alipay-v3 signer and response verifier, with vectors two official SDKs agree on Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cxRP1AqTeP2PWAhwGK --- .../src/connector-credential-door.test.ts | 52 +++++ .../src/connector-materialization.test.ts | 60 ++++- .../services/service-automation/src/plugin.ts | 73 ++++-- .../src/request-signing/alipay-v3.test.ts | 214 ++++++++++++++++++ .../src/request-signing/alipay-v3.ts | 207 +++++++++++++++++ .../aliyun-acs3-hmac-sha256.test.ts | 6 +- .../src/request-signing/index.ts | 30 ++- .../tencentcloud-tc3-hmac-sha256.test.ts | 4 +- .../alipay-v3/GenerateAlipayV3Vectors.java | 79 +++++++ ...ER-test-only-rsa2048-private-key.pkcs8.b64 | 1 + ...LDER-test-only-rsa2048-public-key.spki.b64 | 1 + .../test-vectors/alipay-v3/README.md | 28 +++ .../test-vectors/alipay-v3/generate-node.mjs | 103 +++++++++ .../test-vectors/alipay-v3/generate.sh | 22 ++ 14 files changed, 850 insertions(+), 30 deletions(-) create mode 100644 packages/services/service-automation/src/request-signing/alipay-v3.test.ts create mode 100644 packages/services/service-automation/src/request-signing/alipay-v3.ts create mode 100644 packages/services/service-automation/test-vectors/alipay-v3/GenerateAlipayV3Vectors.java create mode 100644 packages/services/service-automation/test-vectors/alipay-v3/PLACEHOLDER-test-only-rsa2048-private-key.pkcs8.b64 create mode 100644 packages/services/service-automation/test-vectors/alipay-v3/PLACEHOLDER-test-only-rsa2048-public-key.spki.b64 create mode 100644 packages/services/service-automation/test-vectors/alipay-v3/README.md create mode 100644 packages/services/service-automation/test-vectors/alipay-v3/generate-node.mjs create mode 100755 packages/services/service-automation/test-vectors/alipay-v3/generate.sh diff --git a/packages/services/service-automation/src/connector-credential-door.test.ts b/packages/services/service-automation/src/connector-credential-door.test.ts index 7c9dd1fc1b6..975f8bf0a85 100644 --- a/packages/services/service-automation/src/connector-credential-door.test.ts +++ b/packages/services/service-automation/src/connector-credential-door.test.ts @@ -27,6 +27,7 @@ * * The values below are obvious dummies. */ +import { readFileSync } from 'node:fs'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { LiteKernel } from '@objectstack/core'; import { ObjectQL } from '@objectstack/objectql'; @@ -289,6 +290,57 @@ describe('[#27237] a missing credential degrades its instance; setting it materi }); }); +// ADR-0143 D2.3 — an `alipay-v3` instance names two references: the private +// key it signs with and the Alipay public key it verifies responses with. The +// door lists both against the instance, and setting or replacing either one +// re-materializes it. +describe('[#26710] a signed instance names its vendor public key as a second reference', () => { + const vectors = new URL('../test-vectors/alipay-v3/', import.meta.url); + const PRIVATE_KEY = readFileSync(new URL('PLACEHOLDER-test-only-rsa2048-private-key.pkcs8.b64', vectors), 'ascii').trim(); + const PUBLIC_KEY = readFileSync(new URL('PLACEHOLDER-test-only-rsa2048-public-key.spki.b64', vectors), 'ascii').trim(); + const alipay = { + name: 'alipay_pay', + label: 'Alipay', + type: 'api', + provider: 'fake', + providerConfig: {}, + auth: { type: 'signed', scheme: 'alipay-v3', keyId: '2021000000000000', credentialRef: 'ALIPAY_APP_PRIVATE_KEY', vendorPublicKeyRef: 'ALIPAY_PUBLIC_KEY' }, + _packageId: 'com.example.alipay', + }; + + it('lists both references; the public key missing degrades the instance, and setting or replacing it materializes it', async () => { + const materialized: ConnectorProviderContext[] = []; + const signing: ConnectorProviderFactory = (ctx) => { + materialized.push(ctx); + return { + def: { name: ctx.name, label: ctx.label, type: 'api', authentication: { type: 'none' }, actions: [] } as unknown as Connector, + handlers: {}, + requestSignerApplied: true, + }; + }; + const provider = { ...fakeProvider(), factory: signing }; + const { engine } = await boot([alipay], { + provider, + credentialResolver: (ref) => (ref === 'ALIPAY_APP_PRIVATE_KEY' ? PRIVATE_KEY : undefined), + }); + expect(materialized).toHaveLength(0); + expect(engine.getConnectorDegradedReason('alipay_pay')).toContain("credential 'ALIPAY_PUBLIC_KEY'"); + const listed = Object.fromEntries((await engine.listConnectorCredentialRefs()).map((e) => [e.ref, e])); + expect(listed.ALIPAY_PUBLIC_KEY).toMatchObject({ status: 'missing', declared: true }); + expect(listed.ALIPAY_PUBLIC_KEY!.connectors).toEqual([expect.objectContaining({ name: 'alipay_pay', packageId: 'com.example.alipay' })]); + expect(listed.ALIPAY_APP_PRIVATE_KEY!.connectors).toEqual([expect.objectContaining({ name: 'alipay_pay' })]); + + await engine.setConnectorCredential({ ref: 'ALIPAY_PUBLIC_KEY', value: PUBLIC_KEY, context: ADMIN }); + expect(engine.getConnectorDegradedReason('alipay_pay')).toBeUndefined(); + expect(materialized).toHaveLength(1); + expect(materialized[0]!.requestSigner?.scheme).toBe('alipay-v3'); + + // A replaced public key is a new revision: the instance re-materializes. + await engine.setConnectorCredential({ ref: 'ALIPAY_PUBLIC_KEY', value: `\n${PUBLIC_KEY}\n`, context: ADMIN }); + expect(materialized).toHaveLength(2); + }); +}); + describe('[#27237] the value appears nowhere it is not used', () => { it('not in a log record, the listing, the connector descriptors or an engine read of the row', async () => { const out = captureOutput(); diff --git a/packages/services/service-automation/src/connector-materialization.test.ts b/packages/services/service-automation/src/connector-materialization.test.ts index 630e9cf2b8b..b2d68625fe1 100644 --- a/packages/services/service-automation/src/connector-materialization.test.ts +++ b/packages/services/service-automation/src/connector-materialization.test.ts @@ -9,7 +9,7 @@ // Boot fails loudly for unknown provider / name conflict / factory failure; a // credentialRef nothing resolves degrades the instance naming it (#27237). -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import * as path from 'node:path'; import { describe, it, expect, afterAll, afterEach, vi } from 'vitest'; @@ -30,6 +30,7 @@ import { } from './plugin.js'; import type { AutomationEngine } from './engine.js'; import { computeAliyunAcs3Signature } from './request-signing/aliyun-acs3-hmac-sha256.js'; +import { computeAlipayV3Signature, readAlipayPrivateKey } from './request-signing/alipay-v3.js'; const flush = () => new Promise((r) => setTimeout(r, 0)); @@ -1181,6 +1182,63 @@ describe('ADR-0143 D2.3 — a `signed` instance is handed a request signer, neve await kernel.shutdown(); }); + // `alipay-v3` names the Alipay public key beside the private key; it + // resolves through the same chain, and a response is never accepted + // unverified for want of it. + describe('alipay-v3: the vendor public key is a second reference', () => { + const vectors = new URL('../test-vectors/alipay-v3/', import.meta.url); + const PRIVATE_KEY = readFileSync(new URL('PLACEHOLDER-test-only-rsa2048-private-key.pkcs8.b64', vectors), 'ascii').trim(); + const PUBLIC_KEY = readFileSync(new URL('PLACEHOLDER-test-only-rsa2048-public-key.spki.b64', vectors), 'ascii').trim(); + const alipay: ConnectorInstanceAuth = { + type: 'signed', + scheme: 'alipay-v3', + keyId: '2021000000000000', + credentialRef: 'ALIPAY_APP_PRIVATE_KEY', + vendorPublicKeyRef: 'ALIPAY_PUBLIC_KEY', + }; + const both = (ref: string) => ({ ALIPAY_APP_PRIVATE_KEY: PRIVATE_KEY, ALIPAY_PUBLIC_KEY: PUBLIC_KEY } as Record)[ref]; + + it('binds a signer that signs with the private key and verifies with the public key; neither key is in the context', async () => { + const { factory, calls } = makeSigningProvider(true); + const kernel = await boot([providerConnector('alipay_pay', { auth: alipay })], { providerFactory: factory, credentialResolver: both }); + const ctx = calls[0]!; + expect([ctx.requestSigner?.scheme, typeof ctx.requestSigner?.verifyResponse]).toEqual(['alipay-v3', 'function']); + expect(JSON.stringify(ctx)).not.toContain(PRIVATE_KEY.slice(40, 120)); + + const request = { method: 'POST', url: 'https://openapi.alipay.com/v3/alipay/trade/query', headers: {}, body: '{}' }; + const { authorization } = (await ctx.requestSigner!.sign(request)).headers; + const [, nonce, timestamp] = /nonce=([^,]+),timestamp=(\d+),/.exec(authorization!)!; + expect(authorization).toBe( + computeAlipayV3Signature(request, '2021000000000000', readAlipayPrivateKey(PRIVATE_KEY), nonce!, Number(timestamp)).headers.authorization, + ); + // An unsigned 2xx is refused: the public key is in force. + await expect(ctx.requestSigner!.verifyResponse!({ status: 200, headers: { get: () => null }, body: '{}' })).rejects.toThrow(/no complete Alipay signature/); + await kernel.shutdown(); + }); + + it('degrades the instance naming the public-key reference when only it does not resolve, without calling its factory', async () => { + const { factory, calls } = makeSigningProvider(true); + const kernel = await boot([providerConnector('alipay_pay', { auth: alipay })], { + providerFactory: factory, + credentialResolver: (ref) => (ref === 'ALIPAY_APP_PRIVATE_KEY' ? PRIVATE_KEY : undefined), + }); + expect(calls).toHaveLength(0); + const engine = automationOf(kernel); + expect(engine.getConnectorDegradedReason('alipay_pay')).toContain('ALIPAY_PUBLIC_KEY'); + await kernel.shutdown(); + }); + + it('fails boot when the public key is not a key: a configuration fault, its value not echoed', async () => { + const { factory } = makeSigningProvider(true); + const boot26710 = boot([providerConnector('alipay_pay', { auth: alipay })], { + providerFactory: factory, + credentialResolver: (ref) => (ref === 'ALIPAY_PUBLIC_KEY' ? 'not-a-public-key-26710' : both(ref)), + }); + await expect(boot26710).rejects.toThrow(/a key it names cannot sign or verify/); + await expect(boot26710).rejects.not.toThrow(/not-a-public-key-26710/); + }); + }); + it('CONTROL: a static kind is handed its credential and no signer', async () => { const { factory, calls } = makeFakeProvider(); const kernel = await boot([providerConnector('k_bearer', { auth: { type: 'bearer', credentialRef: 'TOKEN' } })], { diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 58cfaf79a26..af8af283e2f 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -397,6 +397,19 @@ function credentialRefOf(auth: ConnectorInstanceAuth | undefined): string | unde return auth.credentialRef; } +/** + * ADR-0143 D2.3 — the vendor public-key reference a `signed` instance names + * beside its key, for a scheme whose vendor signs its responses (`alipay-v3`). + */ +function vendorPublicKeyRefOf(auth: ConnectorInstanceAuth | undefined): string | undefined { + return auth?.type === 'signed' && auth.scheme === 'alipay-v3' ? auth.vendorPublicKeyRef : undefined; +} + +/** [#27237] Every reference an instance's `auth` names: its `credentialRef`, and a vendor public key's. */ +function credentialRefsOf(auth: ConnectorInstanceAuth | undefined): string[] { + return [credentialRefOf(auth), vendorPublicKeyRefOf(auth)].filter((ref): ref is string => ref !== undefined); +} + /** The `basic` username an instance sends beside its secret — what its `Authorization` header encodes with it. */ function basicUsernameOf(auth: ConnectorInstanceAuth | undefined): string | undefined { return auth?.type === 'basic' ? auth.username : undefined; @@ -2211,7 +2224,7 @@ export class AutomationServicePlugin implements Plugin { // read is not one that is absent, so the rows count as unreadable then. let chain: ConnectorCredentialChain = { vault: this.options.credentialResolver }; let credentialRows: Map | undefined = new Map(); - if ([...desired.values()].some((d) => credentialRefOf(d.entry.auth) !== undefined)) { + if ([...desired.values()].some((d) => credentialRefsOf(d.entry.auth).length > 0)) { try { chain = await this.connectorCredentialChain(ctx); if (chain.store) credentialRows = await chain.store.list(); @@ -2227,11 +2240,11 @@ export class AutomationServicePlugin implements Plugin { } const credentialSources = new Map(); for (const d of desired.values()) { - const ref = credentialRefOf(d.entry.auth); - if (ref === undefined) continue; - const fingerprint = connectorCredentialFingerprint(chain, ref, credentialRows); - credentialSources.set(ref, fingerprint); - d.signature += `|credential:${fingerprint}`; + for (const ref of credentialRefsOf(d.entry.auth)) { + const fingerprint = connectorCredentialFingerprint(chain, ref, credentialRows); + credentialSources.set(ref, fingerprint); + d.signature += `|credential:${fingerprint}`; + } } // A refusal of a value the door does not hold stands until an upstream // accepts the reference — or the reference stops resolving from the @@ -2355,20 +2368,48 @@ export class AutomationServicePlugin implements Plugin { } credential = resolution; } + // ADR-0143 D2.3 — a scheme that verifies responses names the + // vendor's public key beside the signing key; it resolves through + // the same chain and degrades the instance the same way, so a + // response is never accepted unverified for want of it. + const vendorRef = vendorPublicKeyRefOf(entry.auth); + let vendorPublicKey: string | undefined; + if (vendorRef !== undefined) { + let resolution: ConnectorCredentialResolution; + try { + resolution = await resolveConnectorCredential(chain, vendorRef, credentialRows); + } catch (err) { + fail( + `[Automation] connector instance '${name}' (provider '${provider}'): auth resolution failed (ADR-0097 §3).`, + err, + ); + continue; + } + if (resolution.kind !== 'resolved') { + await this.degradeConnectorForCredential(engine, ctx, { + name, entry, provider, signature, existing, ref: vendorRef, resolution, + }); + continue; + } + vendorPublicKey = resolution.value; + } // ADR-0143 D2.3, D4 — a `signed` instance's key is bound to the // signer its scheme names, here, and the provider receives only // the signer: the key never crosses into a provider package. A key - // that cannot sign at all is a configuration fault, like an - // unparseable retry policy below. + // that cannot sign (or a vendor key that cannot verify) is a + // configuration fault, like an unparseable retry policy below. let auth: ResolvedConnectorAuth | undefined; let requestSigner: ConnectorRequestSigner | undefined; if (entry.auth?.type === 'signed') { try { - requestSigner = createConnectorRequestSigner(name, entry.auth, credential?.value ?? ''); + requestSigner = createConnectorRequestSigner(name, entry.auth, { + key: credential?.value ?? '', + ...(vendorPublicKey === undefined ? {} : { vendorPublicKey }), + }); } catch (err) { fail( - `[Automation] connector instance '${name}' (provider '${provider}'): its signing key cannot sign ` + - `(ADR-0143 D2.3).`, + `[Automation] connector instance '${name}' (provider '${provider}'): a key it names cannot sign or ` + + `verify (ADR-0143 D2.3).`, err, ); continue; @@ -2873,12 +2914,12 @@ export class AutomationServicePlugin implements Plugin { const entry = raw as DeclaredConnectorItem & { _packageId?: unknown }; if (typeof entry?.name !== 'string' || entry.name.length === 0) continue; if (typeof entry.provider !== 'string' || entry.provider.length === 0) continue; - const ref = credentialRefOf(entry.auth); - if (ref === undefined) continue; const packageId = typeof entry._packageId === 'string' && entry._packageId.length > 0 ? entry._packageId : undefined; - const list = out.get(ref) ?? []; - if (!list.some((c) => c.name === entry.name)) list.push({ name: entry.name, ...(packageId ? { packageId } : {}) }); - out.set(ref, list); + for (const ref of credentialRefsOf(entry.auth)) { + const list = out.get(ref) ?? []; + if (!list.some((c) => c.name === entry.name)) list.push({ name: entry.name, ...(packageId ? { packageId } : {}) }); + out.set(ref, list); + } } return out; } diff --git a/packages/services/service-automation/src/request-signing/alipay-v3.test.ts b/packages/services/service-automation/src/request-signing/alipay-v3.test.ts new file mode 100644 index 00000000000..8803f175bda --- /dev/null +++ b/packages/services/service-automation/src/request-signing/alipay-v3.test.ts @@ -0,0 +1,214 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0143 D2.3 vector rule, as its dated note of 2026-10-31 records for +// Alipay: the vendor publishes no worked example with a usable key, and no +// official SDK test suite carries a byte-exact v3 vector (checked: Java v3, +// PHP v3, .NET v3 and the Node SDK). The vectors below are therefore produced +// by the vendor's two official SDKs and agree byte for byte: +// - com.alipay.sdk:alipay-sdk-java-v3:3.1.89.ALL (`AlipayConfigUtil.sign` / +// `generateSign` / `verify`) signed the two requests and the response; +// - alipay-sdk 4.14.0 (Node) replayed each request through its own `curl` +// with the same nonce and timestamp and sent the identical Authorization, +// computed the identical response signature, and accepted the Java-signed +// response (refusing it tampered). +// Both used the PLACEHOLDER test-only RSA-2048 key pair in +// `test-vectors/alipay-v3/`, which belongs to no Alipay app; one pair plays +// both roles (app key and "Alipay" key). Regenerate and re-check with +// `test-vectors/alipay-v3/generate.sh`. + +import { readFileSync } from 'node:fs'; +import { generateKeyPairSync } from 'node:crypto'; +import { describe, it, expect } from 'vitest'; +import { + alipayV3ResponseMustVerify, + computeAlipayV3Signature, + createAlipayV3Signer, + readAlipayPrivateKey, +} from './alipay-v3.js'; +import { createConnectorRequestSigner } from './index.js'; + +const vectors = new URL('../../test-vectors/alipay-v3/', import.meta.url); +const PLACEHOLDER_PRIVATE_KEY = readFileSync(new URL('PLACEHOLDER-test-only-rsa2048-private-key.pkcs8.b64', vectors), 'ascii').trim(); +const PLACEHOLDER_PUBLIC_KEY = readFileSync(new URL('PLACEHOLDER-test-only-rsa2048-public-key.spki.b64', vectors), 'ascii').trim(); + +const APP_ID = '2021000000000000'; +const GATEWAY = 'https://openapi.alipay.com'; + +const POST = { + request: { + method: 'POST', + url: `${GATEWAY}/v3/alipay/trade/query`, + headers: { 'Content-Type': 'application/json' }, + body: '{"out_trade_no":"20150320010101001"}', + }, + nonce: '5403e89b-3c6d-4aa6-aa38-ed35f621c7d0', + timestamp: 1793430044019, + authorization: 'ALIPAY-SHA256withRSA app_id=2021000000000000,nonce=5403e89b-3c6d-4aa6-aa38-ed35f621c7d0,timestamp=1793430044019,' + + 'sign=F++vtXgeMf7Ylc4toVFglAtBvKEcOTfcSII90v5nhpbKdHWNDN3H78ozATEXFHVYRBLLWWoWqVOTiSiuGqHpMoI4CaPII191U1U9Lc9L6LE2kq0G8EDTi0TlAA74' + + 'ynSPcGcLbinGqwp68DdfzSTAbUDPHifcRn319GREDHhT8wP4tOPvYWVNhC8GaDUCuDohn/M2NlhCXMi9AxaLkT1B/b9iqwgqoJvyNLZUINcaSb9EDdmr6wfOnvJSmKIpR' + + 'w/qAlNbWG4YSjWVybOHnfZU/m4xIE4KpeujZnEt0JQlpB9Fx3cvjErKsJfHBoULhw8UZeumCvqLkfTnIBURCjWPQA==', +}; + +const GET = { + request: { + method: 'GET', + url: `${GATEWAY}/v3/alipay/user/certify/open/query?certify_id=OC201809253000000393900404029253`, + headers: {}, + }, + nonce: '8578aa54-0e36-49d9-9b79-70b96f68d061', + timestamp: 1793430044232, + authorization: 'ALIPAY-SHA256withRSA app_id=2021000000000000,nonce=8578aa54-0e36-49d9-9b79-70b96f68d061,timestamp=1793430044232,' + + 'sign=JobNMH8jUuGsYhTwBOMk5k8YABtwfW42iggPERoqWOWTe0KszAUAxqYTZElHgBVda1rqIwGFkhLi5LN7lb6/I6+RbYJ7AzuclzYt+DnlmUyGygTwme5Ysz4r0Uzo' + + 'UswNZAhszlbzZ16mhCRwuOrkk/GvnApmTGRabAA2nMK5E+jXRFzWnitQg/Qd2P7i1xgqK2LpkN7kwbyQL15WPczDbC3PmAQ6X7lhGIPR/PJcNQ7rKWaOTVo0LV7dEe+nr5' + + '5lc9mmu+rF//ykizv+fUwckDkNp7cD0Gi/r8iowEfopoMkZT91SsqlqWcU2DB+S+H6b6PzrEfSUvWdoViAjIUMjQ==', +}; + +const RESPONSE = { + body: '{"trade_no":"2013112011001004330000121536","out_trade_no":"20150320010101001","trade_status":"TRADE_SUCCESS"}', + timestamp: '1730359200000', + nonce: '8d2b6d3c-5f0e-4a8e-9b0a-6b1f2c3d4e5f', + signature: 'fllDbBuHu1g90OE1GKtKSXEnbIwxfSJO/ysiwCiojDz/YD6Gr1Z08+dvwtCVhFUoCsjCDwmOlgadWwpxeHGpFHrW4iEtCyBQeVcf6w2zSbI3idrUy2OxiVR6auIUDI5f3' + + 'tjBmE1JP960kQWdsoORq/k6ec8SK3CaMElLkV+aRcCTD3GbvMp57boCsA9u3rZurf/7gENa9rgI21kCnpCYisWM9duNqqBcHr8SktidtrXbZX4cwkclRiVImkqCdQoCTKZx' + + 'EflQOXqs2oSyuYGZZ5CuXfj2JSWgPyDcduwrqGxHOmI7BNwJ6Oe/6z5VaEkOhoX5iuTYtD69ulvnvYzGqg==', +}; + +/** A response as the provider hands it to the signer: status, headers, raw body. */ +function response(status: number, body: string, headers: Record) { + const lower = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])); + return { status, body, headers: { get: (name: string) => lower[name.toLowerCase()] ?? null } }; +} + +const signedHeaders = { + 'alipay-timestamp': RESPONSE.timestamp, + 'alipay-nonce': RESPONSE.nonce, + 'alipay-signature': RESPONSE.signature, +}; + +const signer = () => createAlipayV3Signer('alipay_pay', APP_ID, PLACEHOLDER_PRIVATE_KEY, PLACEHOLDER_PUBLIC_KEY); + +describe('alipay-v3 — the vectors both official SDKs (Java v3 3.1.89, Node 4.14.0) agree on, byte for byte', () => { + const key = readAlipayPrivateKey(PLACEHOLDER_PRIVATE_KEY); + + it('reproduces the POST (JSON body) request signature', () => { + const computed = computeAlipayV3Signature(POST.request, APP_ID, key, POST.nonce, POST.timestamp); + expect(computed.headers.authorization).toBe(POST.authorization); + }); + + it('reproduces the GET (query, no body) request signature', () => { + const computed = computeAlipayV3Signature(GET.request, APP_ID, key, GET.nonce, GET.timestamp); + expect(computed.headers.authorization).toBe(GET.authorization); + }); + + it('signs the string the vendor specifies: auth string, method, path and query, body, each line-terminated', () => { + const computed = computeAlipayV3Signature(GET.request, APP_ID, key, GET.nonce, GET.timestamp); + expect(computed.content).toBe( + `app_id=${APP_ID},nonce=${GET.nonce},timestamp=${GET.timestamp}\nGET\n` + + '/v3/alipay/user/certify/open/query?certify_id=OC201809253000000393900404029253\n\n', + ); + }); + + it('the bound signer answers the same bytes for the same nonce and instant', async () => { + const bound = createAlipayV3Signer('alipay_pay', APP_ID, PLACEHOLDER_PRIVATE_KEY, PLACEHOLDER_PUBLIC_KEY, { + now: () => new Date(POST.timestamp), + nonce: () => POST.nonce, + }); + expect((await bound.sign(POST.request)).headers.authorization).toBe(POST.authorization); + }); + + it('a wrong key does not produce the vendor signature', () => { + // CONTROL: the same key in another encoding (PKCS#1 PEM) signs identically. + const pkcs1 = readAlipayPrivateKey(key.export({ format: 'pem', type: 'pkcs1' }).toString()); + expect(computeAlipayV3Signature(POST.request, APP_ID, pkcs1, POST.nonce, POST.timestamp).headers.authorization).toBe(POST.authorization); + const wrong = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey; + const computed = computeAlipayV3Signature(POST.request, APP_ID, wrong, POST.nonce, POST.timestamp); + expect(computed.headers.authorization).not.toBe(POST.authorization); + }); + + it('accepts the response the Java SDK signed', async () => { + await expect(signer().verifyResponse!(response(200, RESPONSE.body, signedHeaders))).resolves.toBeUndefined(); + }); +}); + +describe('alipay-v3 — response verification', () => { + it('refuses a tampered body, a tampered nonce and a missing signature on a 2xx', async () => { + await expect(signer().verifyResponse!(response(200, `${RESPONSE.body} `, signedHeaders))).rejects.toThrow(/does not verify/); + await expect(signer().verifyResponse!(response(200, RESPONSE.body, { ...signedHeaders, 'alipay-nonce': 'x' }))).rejects.toThrow(/does not verify/); + const { 'alipay-signature': _s, ...unsigned } = signedHeaders; + await expect(signer().verifyResponse!(response(200, RESPONSE.body, unsigned))).rejects.toThrow(/carries no complete Alipay signature/); + }); + + it('refuses an unsigned empty 2xx: every status below 400 is verified (Node SDK 4.14.0; Java v3 3.1.89 skips it)', async () => { + await expect(signer().verifyResponse!(response(204, '', {}))).rejects.toThrow(/HTTP 204/); + }); + + it('verifies an error response that carries a signature (Java SDK), and passes an unsigned one as the failure it is', async () => { + await expect(signer().verifyResponse!(response(400, `${RESPONSE.body} `, signedHeaders))).rejects.toThrow(/does not verify/); + await expect(signer().verifyResponse!(response(400, '{"code":"invalid-parameter"}', {}))).resolves.toBeUndefined(); + }); + + it('decides which responses must verify in one place', () => { + expect([200, 204, 302, 399].map((s) => alipayV3ResponseMustVerify(s, false))).toEqual([true, true, true, true]); + expect([400, 500].map((s) => alipayV3ResponseMustVerify(s, false))).toEqual([false, false]); + expect([400, 500].map((s) => alipayV3ResponseMustVerify(s, true))).toEqual([true, true]); + }); + + it('never puts the body or the signature in its refusal', async () => { + const refusal = await signer().verifyResponse!(response(200, `${RESPONSE.body} `, signedHeaders)).catch((e: Error) => e.message); + expect(refusal).not.toContain(RESPONSE.signature); + expect(refusal).not.toContain('trade_no'); + }); +}); + +describe('alipay-v3 — beyond the vectors', () => { + it('chooses a fresh nonce and instant on every request, so a retry is signed afresh', async () => { + const bound = signer(); + const first = await bound.sign(POST.request); + const second = await bound.sign(POST.request); + expect(first.headers.authorization).not.toBe(second.headers.authorization); + expect(first.headers.authorization).toMatch(/^ALIPAY-SHA256withRSA app_id=2021000000000000,nonce=[0-9a-f-]{36},timestamp=\d{13},sign=[A-Za-z0-9+/]+=*$/); + }); + + it('reads the keys as Alipay issues them (bare base64) and as PEM', async () => { + const pem = readAlipayPrivateKey(PLACEHOLDER_PRIVATE_KEY).export({ format: 'pem', type: 'pkcs8' }).toString(); + const bound = createAlipayV3Signer('alipay_pay', APP_ID, pem, PLACEHOLDER_PUBLIC_KEY, { now: () => new Date(POST.timestamp), nonce: () => POST.nonce }); + expect((await bound.sign(POST.request)).headers.authorization).toBe(POST.authorization); + }); + + it('refuses to bind a key that is not an RSA key, without echoing it', () => { + expect(() => createAlipayV3Signer('alipay_pay', APP_ID, 'not-a-key', PLACEHOLDER_PUBLIC_KEY)).toThrow(/credentialRef names cannot sign/); + expect(() => createAlipayV3Signer('alipay_pay', APP_ID, PLACEHOLDER_PRIVATE_KEY, 'not-a-key')).toThrow(/vendorPublicKeyRef names cannot verify/); + let message = ''; + try { + createAlipayV3Signer('alipay_pay', APP_ID, 'not-a-key-26710', PLACEHOLDER_PUBLIC_KEY); + } catch (err) { + message = (err as Error).message; + } + expect(message).not.toContain('not-a-key-26710'); + }); +}); + +describe('createConnectorRequestSigner — alipay-v3', () => { + const auth = { + type: 'signed', scheme: 'alipay-v3', keyId: APP_ID, credentialRef: 'ALIPAY_APP_PRIVATE_KEY', vendorPublicKeyRef: 'ALIPAY_PUBLIC_KEY', + } as const; + + it('binds the scheme to both keys: it signs and it verifies', async () => { + const bound = createConnectorRequestSigner('alipay_pay', auth, { key: PLACEHOLDER_PRIVATE_KEY, vendorPublicKey: PLACEHOLDER_PUBLIC_KEY }, { + now: () => new Date(POST.timestamp), + nonce: () => POST.nonce, + }); + expect([bound.connector, bound.scheme]).toEqual(['alipay_pay', 'alipay-v3']); + expect((await bound.sign(POST.request)).headers.authorization).toBe(POST.authorization); + await expect(bound.verifyResponse!(response(200, RESPONSE.body, signedHeaders))).resolves.toBeUndefined(); + }); + + it('refuses to bind without the Alipay public key: no response is accepted unverified', () => { + expect(() => createConnectorRequestSigner('alipay_pay', auth, { key: PLACEHOLDER_PRIVATE_KEY })).toThrow(/vendorPublicKeyRef names is empty/); + }); + + it('never carries a key in what it hands back', async () => { + const bound = createConnectorRequestSigner('alipay_pay', auth, { key: PLACEHOLDER_PRIVATE_KEY, vendorPublicKey: PLACEHOLDER_PUBLIC_KEY }); + const signature = await bound.sign(POST.request); + expect(JSON.stringify({ bound, signature })).not.toContain(PLACEHOLDER_PRIVATE_KEY.slice(40, 120)); + }); +}); diff --git a/packages/services/service-automation/src/request-signing/alipay-v3.ts b/packages/services/service-automation/src/request-signing/alipay-v3.ts new file mode 100644 index 00000000000..dd568a4536a --- /dev/null +++ b/packages/services/service-automation/src/request-signing/alipay-v3.ts @@ -0,0 +1,207 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { createPrivateKey, createPublicKey, createSign, createVerify, randomUUID, type KeyObject } from 'node:crypto'; +import type { + ConnectorRequestSignature, + ConnectorRequestSigner, + ConnectorSignableRequest, + ConnectorSignedResponse, +} from '@objectstack/spec/integration'; +import type { RequestSigningClock } from './clock.js'; + +/** + * `alipay-v3` — Alipay's OpenAPI v3 request signature and response + * verification (ADR-0143 D2.3, Q4). Alipay publishes no worked example with a + * usable key and no official SDK test suite carries a byte-exact vector, so + * the vectors in `alipay-v3.test.ts` come from the vendor's two official SDKs + * signing with one placeholder key and agreeing byte for byte (D2.3's dated + * note of 2026-10-31; the generator is `test-vectors/alipay-v3/`). + * + * Every request carries `Authorization: ALIPAY-SHA256withRSA + * app_id=,nonce=,timestamp=,sign=`. The signature is + * RSA PKCS#1 v1.5 with SHA-256, by the application private key, over + * + * \n\n\n\n + * + * where the auth string is the header's `app_id=…,nonce=…,timestamp=…`. The + * nonce and the millisecond timestamp are chosen here, per attempt. + * + * Alipay signs its responses with its own key: `alipay-timestamp`, + * `alipay-nonce` and `alipay-signature` over + * + * \n\n\n + * + * verified here against the Alipay public key before the body is read + * ({@link alipayV3ResponseMustVerify} says which responses). + * + * Keys are accepted as Alipay's console issues them — the bare base64 body — + * or as PEM: the private key PKCS#8 or PKCS#1, the public key SPKI. + * Public-key mode only: certificate mode (`app_cert_sn`) is not implemented. + * + * ⛔ The private key lives only in the closure of the signer this module + * returns: no error, log line or returned value carries it. + */ + +export const ALIPAY_V3_ALGORITHM = 'ALIPAY-SHA256withRSA'; + +function parseKey(text: string, pem: (key: string) => T, der: ReadonlyArray<(key: Buffer) => T>): T { + const trimmed = text.trim(); + if (trimmed.startsWith('-----BEGIN')) return pem(trimmed); + const bytes = Buffer.from(trimmed.replace(/\s+/g, ''), 'base64'); + let last: unknown; + for (const read of der) { + try { + return read(bytes); + } catch (err) { + last = err; + } + } + throw last; +} + +/** An RSA private key from PEM or bare base64 (PKCS#8, else PKCS#1). Throws without echoing the key. */ +export function readAlipayPrivateKey(text: string): KeyObject { + let key: KeyObject; + try { + key = parseKey(text, (k) => createPrivateKey(k), [ + (b) => createPrivateKey({ key: b, format: 'der', type: 'pkcs8' }), + (b) => createPrivateKey({ key: b, format: 'der', type: 'pkcs1' }), + ]); + } catch { + throw new Error('it is not an RSA private key in PEM or base64 (PKCS#8 or PKCS#1)'); + } + if (key.asymmetricKeyType !== 'rsa') throw new Error('it is not an RSA private key'); + return key; +} + +/** An RSA public key from PEM or bare base64 (SPKI, else PKCS#1). */ +export function readAlipayPublicKey(text: string): KeyObject { + let key: KeyObject; + try { + key = parseKey(text, (k) => createPublicKey(k), [ + (b) => createPublicKey({ key: b, format: 'der', type: 'spki' }), + (b) => createPublicKey({ key: b, format: 'der', type: 'pkcs1' }), + ]); + } catch { + throw new Error('it is not an RSA public key in PEM or base64 (SPKI or PKCS#1)'); + } + if (key.asymmetricKeyType !== 'rsa') throw new Error('it is not an RSA public key'); + return key; +} + +/** Every intermediate of one request signature — what the vector test pins. */ +export interface AlipayV3Computation { + readonly authString: string; + readonly content: string; + readonly signature: string; + readonly headers: Readonly>; +} + +/** + * Sign `request` with `privateKey` at `timestamp` (Unix milliseconds) with + * `nonce`. Pure: RSA PKCS#1 v1.5 is deterministic, so the same inputs give the + * same bytes, which is what lets the SDK vectors pin it. + */ +export function computeAlipayV3Signature( + request: ConnectorSignableRequest, + appId: string, + privateKey: KeyObject, + nonce: string, + timestamp: number, +): AlipayV3Computation { + const url = new URL(request.url); + const authString = `app_id=${appId},nonce=${nonce},timestamp=${timestamp}`; + const content = `${authString}\n${request.method.toUpperCase()}\n${url.pathname}${url.search}\n${request.body ?? ''}\n`; + const signature = createSign('RSA-SHA256').update(content, 'utf8').sign(privateKey, 'base64'); + return { + authString, + content, + signature, + headers: { authorization: `${ALIPAY_V3_ALGORITHM} ${authString},sign=${signature}` }, + }; +} + +/** + * Which responses must carry a valid Alipay signature. A deliberate choice + * where the vendor's two official SDKs disagree (seat ruling on #26710, + * 2026-10-31), taking the stricter of the two in each case: + * - status below 400, whatever the body (an empty one included): verified, + * and a missing signature fails. The Node SDK (alipay-sdk 4.14.0) verifies + * every such response; the Java v3 SDK (3.1.89) skips an empty 2xx body. + * - status 400 and above: verified when it carries `alipay-signature` (the + * Java SDK's rule; the Node SDK never verifies one), and an unsigned one + * passes as the failure it already is. + */ +export function alipayV3ResponseMustVerify(status: number, signed: boolean): boolean { + return status < 400 || signed; +} + +/** The string Alipay signs over a response. */ +export function alipayV3ResponseContent(timestamp: string, nonce: string, body: string): string { + return `${timestamp}\n${nonce}\n${body}\n`; +} + +/** + * Verify Alipay's signature over `response` with `publicKey`, as + * {@link alipayV3ResponseMustVerify} requires. Throws naming the instance and + * the status, never the body or the signature. + */ +export function verifyAlipayV3Response(connector: string, response: ConnectorSignedResponse, publicKey: KeyObject): void { + const signature = response.headers.get('alipay-signature'); + if (!alipayV3ResponseMustVerify(response.status, signature !== null && signature !== '')) return; + const timestamp = response.headers.get('alipay-timestamp'); + const nonce = response.headers.get('alipay-nonce'); + if (!signature || timestamp === null || nonce === null) { + throw new Error( + `connector instance '${connector}': the response (HTTP ${response.status}) carries no complete Alipay signature ` + + '(alipay-signature, alipay-timestamp, alipay-nonce), so it is not accepted as Alipay\'s', + ); + } + const verified = createVerify('RSA-SHA256') + .update(alipayV3ResponseContent(timestamp, nonce, response.body), 'utf8') + .verify(publicKey, signature, 'base64'); + if (!verified) { + throw new Error( + `connector instance '${connector}': the response's Alipay signature (HTTP ${response.status}) does not verify ` + + 'against the public key its vendorPublicKeyRef names, so it is not accepted as Alipay\'s', + ); + } +} + +/** + * Bind an `alipay-v3` signer to one instance's app id, application private key + * and Alipay public key. Throws when either key cannot be read, before any + * request is made; never with a key in the message. + */ +export function createAlipayV3Signer( + connector: string, + appId: string, + privateKeyText: string, + publicKeyText: string, + clock: RequestSigningClock = {}, +): ConnectorRequestSigner { + let privateKey: KeyObject; + let publicKey: KeyObject; + try { + privateKey = readAlipayPrivateKey(privateKeyText); + } catch (err) { + throw new Error(`connector instance '${connector}': the signing key its credentialRef names cannot sign: ${(err as Error).message}`); + } + try { + publicKey = readAlipayPublicKey(publicKeyText); + } catch (err) { + throw new Error(`connector instance '${connector}': the key its vendorPublicKeyRef names cannot verify: ${(err as Error).message}`); + } + const now = clock.now ?? (() => new Date()); + const nonce = clock.nonce ?? (() => randomUUID()); + return { + connector, + scheme: 'alipay-v3', + async sign(request): Promise { + return { headers: computeAlipayV3Signature(request, appId, privateKey, nonce(), now().getTime()).headers }; + }, + async verifyResponse(response): Promise { + verifyAlipayV3Response(connector, response, publicKey); + }, + }; +} diff --git a/packages/services/service-automation/src/request-signing/aliyun-acs3-hmac-sha256.test.ts b/packages/services/service-automation/src/request-signing/aliyun-acs3-hmac-sha256.test.ts index 31fd585ffab..9707b8d118b 100644 --- a/packages/services/service-automation/src/request-signing/aliyun-acs3-hmac-sha256.test.ts +++ b/packages/services/service-automation/src/request-signing/aliyun-acs3-hmac-sha256.test.ts @@ -139,7 +139,7 @@ describe('createConnectorRequestSigner — the signer registry', () => { const auth = { type: 'signed', scheme: 'aliyun-acs3-hmac-sha256', keyId: VENDOR_KEY_ID, credentialRef: 'ALIYUN_SECRET' } as const; it('binds the scheme the instance names to its key', async () => { - const signer = createConnectorRequestSigner('aliyun_ecs', auth, VENDOR_SECRET, { + const signer = createConnectorRequestSigner('aliyun_ecs', auth, { key: VENDOR_SECRET }, { now: () => new Date('2023-10-26T10:22:32Z'), nonce: () => VENDOR_NONCE, }); @@ -148,11 +148,11 @@ describe('createConnectorRequestSigner — the signer registry', () => { }); it('refuses an empty key before any request, without echoing a key', () => { - expect(() => createConnectorRequestSigner('aliyun_ecs', auth, '')).toThrow(/signing key its credentialRef names is empty/); + expect(() => createConnectorRequestSigner('aliyun_ecs', auth, { key: '' })).toThrow(/signing key its credentialRef names is empty/); }); it('never carries the key in what it hands back', async () => { - const signer = createConnectorRequestSigner('aliyun_ecs', auth, VENDOR_SECRET); + const signer = createConnectorRequestSigner('aliyun_ecs', auth, { key: VENDOR_SECRET }); const signature = await signer.sign(vendorRequest); expect(JSON.stringify({ signer, signature })).not.toContain(VENDOR_SECRET); }); diff --git a/packages/services/service-automation/src/request-signing/index.ts b/packages/services/service-automation/src/request-signing/index.ts index 9d43f0764c1..385b923f059 100644 --- a/packages/services/service-automation/src/request-signing/index.ts +++ b/packages/services/service-automation/src/request-signing/index.ts @@ -3,37 +3,51 @@ 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 { createAlipayV3Signer } from './alipay-v3.js'; import type { RequestSigningClock } from './clock.js'; export type { RequestSigningClock } from './clock.js'; +/** What a `signed` instance's references resolved to. */ +export interface ConnectorSigningKeys { + /** `credentialRef`'s value: the HMAC secret or private key every request is signed with. */ + readonly key: string; + /** `vendorPublicKeyRef`'s value, for a scheme that verifies responses (`alipay-v3`). */ + readonly vendorPublicKey?: string; +} + /** * The signer registry (ADR-0143 D2.3, D4): bind a `signed` instance's key to * the signer its `scheme` names. The host calls it once the instance's - * `credentialRef` has resolved, and hands the provider the signer — never the - * key. + * references have resolved, and hands the provider the signer — never a key. * * The switch is exhaustive over `ConnectorSigningSchemeSchema`'s members, so a * scheme added to the spec without a signer here fails to compile: an enum * member cannot ship ahead of its signer (ADR-0049). * - * Throws when the key cannot sign at all (an empty one), before any request - * is made; never with the key in the message. + * Throws when a key cannot sign or verify at all (an empty one, or one that is + * not the scheme's kind of key), before any request is made; never with a key + * in the message. */ export function createConnectorRequestSigner( connector: string, auth: ConnectorInstanceSignedAuth, - key: string, + keys: ConnectorSigningKeys, clock?: RequestSigningClock, ): ConnectorRequestSigner { - if (key.length === 0) { + if (keys.key.length === 0) { throw new Error(`connector instance '${connector}': the signing key its credentialRef names is empty`); } switch (auth.scheme) { case 'aliyun-acs3-hmac-sha256': - return createAliyunAcs3Signer(connector, auth.keyId, key, clock); + return createAliyunAcs3Signer(connector, auth.keyId, keys.key, clock); case 'tencentcloud-tc3-hmac-sha256': - return createTencentCloudTc3Signer(connector, auth.keyId, key, clock); + return createTencentCloudTc3Signer(connector, auth.keyId, keys.key, clock); + case 'alipay-v3': + if (!keys.vendorPublicKey) { + throw new Error(`connector instance '${connector}': the Alipay public key its vendorPublicKeyRef names is empty`); + } + return createAlipayV3Signer(connector, auth.keyId, keys.key, keys.vendorPublicKey, clock); default: { // Every member handled leaves `auth` itself `never` here. const unsigned: never = auth; 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 index 4d1f2cc18cd..cd2bfd8258b 100644 --- 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 @@ -136,13 +136,13 @@ 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) }); + const signer = createConnectorRequestSigner('tencent_cvm', auth, { key: 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 signer = createConnectorRequestSigner('tencent_cvm', auth, { key: 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/test-vectors/alipay-v3/GenerateAlipayV3Vectors.java b/packages/services/service-automation/test-vectors/alipay-v3/GenerateAlipayV3Vectors.java new file mode 100644 index 00000000000..aa9ee794682 --- /dev/null +++ b/packages/services/service-automation/test-vectors/alipay-v3/GenerateAlipayV3Vectors.java @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The Java half of the `alipay-v3` vectors (ADR-0143 D2.3, as amended by the +// dated note of 2026-10-31): the official Alipay Java v3 SDK, +// com.alipay.sdk:alipay-sdk-java-v3:3.1.89.ALL, signs two requests and one +// response with the PLACEHOLDER test-only key pair in this directory. The +// nonce and timestamp are the SDK's own (it draws them inside `sign`), so this +// half chooses them and `generate-node.mjs` replays them through the official +// Node SDK. `generate.sh` runs both and fails unless the bytes are identical. +// +// Run through `generate.sh`, which fetches the SDK and its two runtime +// dependencies from Maven Central; prints one JSON object on stdout. + +import com.alipay.v3.util.AlipayConfigUtil; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class GenerateAlipayV3Vectors { + static final String APP_ID = "2021000000000000"; + static final String POST_URI = "/v3/alipay/trade/query"; + static final String POST_BODY = "{\"out_trade_no\":\"20150320010101001\"}"; + static final String GET_URI = "/v3/alipay/user/certify/open/query?certify_id=OC201809253000000393900404029253"; + static final String RESPONSE_BODY = "{\"trade_no\":\"2013112011001004330000121536\",\"out_trade_no\":\"20150320010101001\",\"trade_status\":\"TRADE_SUCCESS\"}"; + static final Pattern AUTH = Pattern.compile("^ALIPAY-SHA256withRSA app_id=([^,]+),nonce=([^,]+),timestamp=([^,]+),sign=(.+)$"); + + public static void main(String[] args) throws Exception { + Path dir = Path.of(args.length > 0 ? args[0] : "."); + AlipayConfigUtil sdk = new AlipayConfigUtil(); + sdk.setAppId(APP_ID); + sdk.setCharset("UTF-8"); + sdk.setPrivateKey(read(dir, "PLACEHOLDER-test-only-rsa2048-private-key.pkcs8.b64")); + // One placeholder pair plays both roles: the app key that signs + // requests, and the "Alipay" key whose public half verifies responses. + sdk.setAlipayPublicKey(read(dir, "PLACEHOLDER-test-only-rsa2048-public-key.spki.b64")); + + String post = signed(sdk, "POST", POST_URI, POST_BODY); + String get = signed(sdk, "GET", GET_URI, ""); + + // The response half: the SDK's own RSA signer over the string its + // `verify` checks (timestamp, nonce, body, each line-terminated), then + // its `verify` over the result. + String responseTimestamp = "1730359200000"; + String responseNonce = "8d2b6d3c-5f0e-4a8e-9b0a-6b1f2c3d4e5f"; + String responseSignature = sdk.generateSign(responseTimestamp + "\n" + responseNonce + "\n" + RESPONSE_BODY + "\n"); + if (!sdk.verify(RESPONSE_BODY, responseSignature, null, responseTimestamp, responseNonce)) { + throw new IllegalStateException("the SDK refused its own response signature"); + } + + System.out.println("{\"sdk\":\"com.alipay.sdk:alipay-sdk-java-v3:3.1.89.ALL\",\"appId\":" + json(APP_ID) + + ",\"post\":" + post + ",\"get\":" + get + + ",\"response\":{\"body\":" + json(RESPONSE_BODY) + ",\"timestamp\":" + json(responseTimestamp) + + ",\"nonce\":" + json(responseNonce) + ",\"signature\":" + json(responseSignature) + "}}"); + } + + static String signed(AlipayConfigUtil sdk, String method, String uri, String body) throws Exception { + Map headers = new HashMap<>(); + sdk.sign(method, uri, body, headers); + String authorization = headers.get("Authorization"); + Matcher m = AUTH.matcher(authorization); + if (!m.matches()) throw new IllegalStateException("unexpected Authorization shape"); + return "{\"method\":" + json(method) + ",\"uri\":" + json(uri) + ",\"body\":" + json(body) + + ",\"nonce\":" + json(m.group(2)) + ",\"timestamp\":" + json(m.group(3)) + + ",\"authorization\":" + json(authorization) + "}"; + } + + static String read(Path dir, String name) throws Exception { + return Files.readString(dir.resolve(name), StandardCharsets.US_ASCII).trim(); + } + + static String json(String s) { + return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\""; + } +} diff --git a/packages/services/service-automation/test-vectors/alipay-v3/PLACEHOLDER-test-only-rsa2048-private-key.pkcs8.b64 b/packages/services/service-automation/test-vectors/alipay-v3/PLACEHOLDER-test-only-rsa2048-private-key.pkcs8.b64 new file mode 100644 index 00000000000..480b34e08fe --- /dev/null +++ b/packages/services/service-automation/test-vectors/alipay-v3/PLACEHOLDER-test-only-rsa2048-private-key.pkcs8.b64 @@ -0,0 +1 @@ +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDAQFRVP/wzSqRAV1MYJTZ/R+u4AqfgwSQZnHYuUcPhFAov5hTTh7k97T8iz6jnvAJRd4aIVTokHbgQwrFATYbrjuDCxME80auDxBU5F27jeVc7kXioeIv2I9AEYb1sidE9u0VqJvkAzxBvnRZ58vfcJn2hO9j30p44ka9xI07hur/TDtJsgiaonCtY7T60xTB0iNyfj/1JR/E+Ddxskm+CC/1wGpzVFNEJfkyb2frGJ2bFLhIqd8sSce5aHpdtg6wos0+ZJkW9bodnuw7IyaBzG3shmznUGIjEu3IHrwovXORijqLQa1H4w8f2KEAerAN2tU/PiUi4OT+LqcCjq+r5AgMBAAECggEADJ5MSroj5gPwqu5wXEXZAVqpnI7EEQrsQSgVU633Mtc7YfByjn26iVeAjNrfZbJf3pP/RwR9B/DRa3wz4RoVkHF0zCPY4RKL9N6SXAhvNUv6D0VZi4k5+oJYPWli4vRawPgBMtny/vwYQpMnbgKmBRObrFD+ySlQTDs3ri/YlhUsEkuFt8CEYsuKFoIn8Sc6UyJNsSeg+luzWTOVkymU2lCszwEa1WOr+MfZOOn2IWxlIBW8iccCgXeA0TzMLSI326SoZIWf9uV/TxeKDJe5IKulXuMv8JvzpsOEnH3IaXuy8idkmILkoV3Nu1DkTSF9s6yu3z03Dc6AtL50YwaR3wKBgQDzK7QwcfqaJ8lpbKw9Ke3VHToBL6zmzMTaO65Z4LDSWYhRyz75xpa4I3NjbbagiNHY5c5R03X1gm9/jqSrkrAd456TIDGA2E3FL5fxxUmp9y7YIG+AmnXa6DSAkUi1Taxpe3zNDhRgHYY2+Dw56x4QUCcHCv2+vqApawGC3HMMnwKBgQDKZOajkbYNCP77xIsiQbfQzOZ60YA6GznBlSOQEjmiZtLvmTLGd8XR7kvH9KqQ4WIjdmY7JX0SNXVO0kimFB1xaetXjjs8Eh93hhR25uMZqcsgueYxCgfWb+77OOXvg2H0lobiaV3epcz9VImUtOQbQNaPtpV126EXH+dZJ+fJZwKBgEoJunktmvGov4hb0C1nQMSse2a1D13OZvlTRoiTAwNowzq0OV5veqBiGtXhJ8PFJK93eNQtIDM6HZym37hllz3iefutx6TFNxicnxS7zuvzGM4xQgHUBaI7JREoKw0YszACq0L70HRbSOwftKal0aeB4AAi9pNHExjJPdKMMHNdAoGAZKIq86WdyXlxnymorI3UoeNfri3A/iNPYmuPVTFaqt9JfozD7F7dY3aDA5cpwAPw0gUq67nGKGmgPGLecIWL5jpuJAi6dzHjp4BpaH2A6d+8+ja0uCluoDbjstIlsiO3bu/xfV46qFv0QvnntqMC0HI6vRZh1kowHNlmJLRs0X0CgYEAxR/WtSleXHiNDTpVuf/vIG4nS4yOGjFDs3E3PQYsIZL5Bn2PgL1C80zW3SYLY3JsETIkxNb2YvSGCzkOnrozYyd2bq5iHGG6lsN1Ll+UlRD1yKbrFdFzykMT3wcPfH3eRvlOOhBnyqkp6RRvB5k3ORXs2sYItqYMfa1TARHRed4= \ No newline at end of file diff --git a/packages/services/service-automation/test-vectors/alipay-v3/PLACEHOLDER-test-only-rsa2048-public-key.spki.b64 b/packages/services/service-automation/test-vectors/alipay-v3/PLACEHOLDER-test-only-rsa2048-public-key.spki.b64 new file mode 100644 index 00000000000..7c8212584bc --- /dev/null +++ b/packages/services/service-automation/test-vectors/alipay-v3/PLACEHOLDER-test-only-rsa2048-public-key.spki.b64 @@ -0,0 +1 @@ +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwEBUVT/8M0qkQFdTGCU2f0fruAKn4MEkGZx2LlHD4RQKL+YU04e5Pe0/Is+o57wCUXeGiFU6JB24EMKxQE2G647gwsTBPNGrg8QVORdu43lXO5F4qHiL9iPQBGG9bInRPbtFaib5AM8Qb50WefL33CZ9oTvY99KeOJGvcSNO4bq/0w7SbIImqJwrWO0+tMUwdIjcn4/9SUfxPg3cbJJvggv9cBqc1RTRCX5Mm9n6xidmxS4SKnfLEnHuWh6XbYOsKLNPmSZFvW6HZ7sOyMmgcxt7IZs51BiIxLtyB68KL1zkYo6i0GtR+MPH9ihAHqwDdrVPz4lIuDk/i6nAo6vq+QIDAQAB \ No newline at end of file diff --git a/packages/services/service-automation/test-vectors/alipay-v3/README.md b/packages/services/service-automation/test-vectors/alipay-v3/README.md new file mode 100644 index 00000000000..d9450838167 --- /dev/null +++ b/packages/services/service-automation/test-vectors/alipay-v3/README.md @@ -0,0 +1,28 @@ +# `alipay-v3` signer vectors + +The vectors pinned in `src/request-signing/alipay-v3.test.ts` come from here. They follow the third +branch of ADR-0143 D2.3's vector rule, added by its dated note of 2026-10-31. Alipay publishes no +worked example with a usable key, and no official v3 SDK test suite carries a byte-exact vector. The +vendor's two official SDKs therefore sign with one placeholder key, and the vectors are admitted only +because the two agree byte for byte: + +- `GenerateAlipayV3Vectors.java` uses `com.alipay.sdk:alipay-sdk-java-v3:3.1.89.ALL` + (`AlipayConfigUtil.sign`, `generateSign`, `verify`). It signs a POST and a GET request and one + response. +- `generate-node.mjs` uses `alipay-sdk` 4.14.0 (Node). It replays each request through the SDK's own + `curl`, with the Java SDK's nonce and timestamp, against a local stand-in for the gateway. The run + fails unless: + - the `Authorization` headers are identical to Java's; + - its response signature is identical to Java's; + - it accepts the Java-signed response and refuses it tampered. +- `generate.sh` fetches both SDKs, runs both halves and prints the vectors. It needs a JDK 11+, Maven, + Node 22+ and access to Maven Central and the npm registry. Each run draws a new nonce and timestamp, + so the bytes change from run to run, and the two SDKs must agree on every one. + +## The key pair is a PLACEHOLDER + +`PLACEHOLDER-test-only-rsa2048-private-key.pkcs8.b64` and +`PLACEHOLDER-test-only-rsa2048-public-key.spki.b64` are an RSA-2048 pair generated for these tests on +2026-10-31. They belong to no Alipay app and authenticate nothing. They are bare base64, the form +Alipay's console issues. One pair plays both roles: the app key that signs requests, and the "Alipay" +key whose public half verifies responses. Never use them for anything else. diff --git a/packages/services/service-automation/test-vectors/alipay-v3/generate-node.mjs b/packages/services/service-automation/test-vectors/alipay-v3/generate-node.mjs new file mode 100644 index 00000000000..ebb4a7d8edd --- /dev/null +++ b/packages/services/service-automation/test-vectors/alipay-v3/generate-node.mjs @@ -0,0 +1,103 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The Node half of the `alipay-v3` vectors (see README.md): the official +// Alipay Node SDK, alipay-sdk 4.14.0, replays each request the Java v3 SDK +// signed — same PLACEHOLDER key, same nonce and timestamp — through its own +// `curl` against a local stand-in for the Alipay gateway, which records the +// `Authorization` the SDK sends and answers with the response the Java SDK +// signed. The run fails unless: +// - every Authorization is byte-identical to the Java SDK's; +// - the Node SDK's own response signature is byte-identical to the Java SDK's; +// - the Node SDK accepts the Java-signed response, and refuses it tampered. +// On success it prints the vectors the signer test pins. +// +// Usage (through generate.sh): ALIPAY_SDK_DIR= node generate-node.mjs + +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const [dir, javaOutput] = process.argv.slice(2); +const sdkDir = process.env.ALIPAY_SDK_DIR; +if (!dir || !javaOutput || !sdkDir) { + console.error('usage: ALIPAY_SDK_DIR= node generate-node.mjs '); + process.exit(2); +} + +const require = createRequire(import.meta.url); +// The CommonJS build reads `randomUUID` off the module object at call time, so +// the nonce the Java SDK drew can be replayed; `Date.now` likewise. +const nodeCrypto = require('node:crypto'); +const { AlipaySdk } = require(join(sdkDir, 'dist/commonjs/index.js')); +const { signatureV3 } = require(join(sdkDir, 'dist/commonjs/util.js')); +const sdkVersion = JSON.parse(readFileSync(join(sdkDir, 'package.json'), 'utf8')).version; + +const java = JSON.parse(readFileSync(javaOutput, 'utf8')); +const privateKey = readFileSync(join(dir, 'PLACEHOLDER-test-only-rsa2048-private-key.pkcs8.b64'), 'ascii').trim(); +const publicKey = readFileSync(join(dir, 'PLACEHOLDER-test-only-rsa2048-public-key.spki.b64'), 'ascii').trim(); + +let answer = { body: java.response.body, signature: java.response.signature }; +let lastAuthorization; +const gateway = createServer((req, res) => { + lastAuthorization = req.headers.authorization; + req.resume(); + req.on('end', () => { + res.writeHead(200, { + 'content-type': 'application/json', + 'alipay-timestamp': java.response.timestamp, + 'alipay-nonce': java.response.nonce, + 'alipay-signature': answer.signature, + }); + res.end(answer.body); + }); +}); +await new Promise((resolve) => gateway.listen(0, '127.0.0.1', resolve)); +const endpoint = `http://127.0.0.1:${gateway.address().port}`; + +const sdk = new AlipaySdk({ appId: java.appId, privateKey, keyType: 'PKCS8', alipayPublicKey: publicKey, endpoint }); +const failures = []; + +async function replay(name, vector, curl) { + const realUuid = nodeCrypto.randomUUID; + const realNow = Date.now; + nodeCrypto.randomUUID = () => vector.nonce; + Date.now = () => Number(vector.timestamp); + try { + await curl(); + } finally { + nodeCrypto.randomUUID = realUuid; + Date.now = realNow; + } + if (lastAuthorization !== vector.authorization) failures.push(`${name}: the Node SDK's Authorization differs from the Java SDK's`); +} + +await replay('post', java.post, () => sdk.curl('POST', java.post.uri, { body: JSON.parse(java.post.body) })); +const getUrl = new URL(java.get.uri, endpoint); +await replay('get', java.get, () => sdk.curl('GET', getUrl.pathname, { query: Object.fromEntries(getUrl.searchParams) })); + +const responseContent = `${java.response.timestamp}\n${java.response.nonce}\n${java.response.body}\n`; +const nodeResponseSignature = signatureV3(responseContent, sdk.config.privateKey); +if (nodeResponseSignature !== java.response.signature) failures.push('response: the Node SDK\'s signature differs from the Java SDK\'s'); + +answer = { body: `${java.response.body} `, signature: java.response.signature }; +let refused = false; +try { + await sdk.curl('POST', java.post.uri, { body: JSON.parse(java.post.body) }); +} catch (err) { + refused = err?.code === 'response-signature-verify-error'; +} +if (!refused) failures.push('response: the Node SDK accepted a tampered body'); +gateway.close(); + +if (failures.length > 0) { + console.error(failures.join('\n')); + process.exit(1); +} +console.log(JSON.stringify({ + sdks: [java.sdk, `alipay-sdk@${sdkVersion}`], + appId: java.appId, + post: java.post, + get: java.get, + response: java.response, +}, null, 2)); diff --git a/packages/services/service-automation/test-vectors/alipay-v3/generate.sh b/packages/services/service-automation/test-vectors/alipay-v3/generate.sh new file mode 100755 index 00000000000..47aa59a8d03 --- /dev/null +++ b/packages/services/service-automation/test-vectors/alipay-v3/generate.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +# +# Regenerates and cross-checks the `alipay-v3` signer vectors (README.md): the +# official Java v3 SDK signs, the official Node SDK replays, and the run fails +# unless both produce the same bytes. Needs a JDK 11+, Maven and Node 22+, and +# reaches Maven Central and the npm registry. Prints the vectors on success; +# each run draws a fresh nonce and timestamp, so the bytes differ run to run +# while the two SDKs still agree on every one. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +for artifact in com.alipay.sdk:alipay-sdk-java-v3:3.1.89.ALL com.google.guava:guava:28.1-jre org.bouncycastle:bcprov-jdk15on:1.64; do + mvn -q dependency:copy -Dartifact="$artifact" -DoutputDirectory="$work/lib" +done +java -cp "$work/lib/*" "$here/GenerateAlipayV3Vectors.java" "$here" > "$work/java.json" + +npm install --silent --no-save --ignore-scripts --prefix "$work/node" alipay-sdk@4.14.0 +ALIPAY_SDK_DIR="$work/node/node_modules/alipay-sdk" node "$here/generate-node.mjs" "$here" "$work/java.json" From db85f382d653155afaf4ec5b70ad7cf946a6724a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 07:20:07 +0000 Subject: [PATCH 3/8] test(connector-openapi): the alipay-v3 acceptance, end to end against a local gateway stand-in Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cxRP1AqTeP2PWAhwGK --- .../src/alipay-v3-acceptance.test.ts | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 packages/connectors/connector-openapi/src/alipay-v3-acceptance.test.ts diff --git a/packages/connectors/connector-openapi/src/alipay-v3-acceptance.test.ts b/packages/connectors/connector-openapi/src/alipay-v3-acceptance.test.ts new file mode 100644 index 00000000000..c2b7f712a0b --- /dev/null +++ b/packages/connectors/connector-openapi/src/alipay-v3-acceptance.test.ts @@ -0,0 +1,203 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * **#26710 acceptance (ADR-0143 Acceptance, `signed` line)**, as the + * maintainer ruled on 2026-10-31: the local end-to-end run counts as the + * acceptance, with no live sandbox call owed. + * + * A declarative `provider: 'openapi'` instance over an Alipay v3-shaped + * document (JSON; the `alipay.trade.query` operation as the vendor's document + * declares it) with `auth: { type: 'signed', scheme: 'alipay-v3', … }` + * dispatches through a real boot (LiteKernel + AutomationServicePlugin + + * ConnectorOpenApiPlugin). A local stand-in for the Alipay gateway (the + * provider's `fetchImpl`) checks the request's `ALIPAY-SHA256withRSA` + * signature with the app's public key and signs its answer with its own key, + * as Alipay does. Two key pairs, generated here, keep the roles apart: the app + * pair signs requests, the "Alipay" pair signs responses. + * + * 1. A signed request is accepted by the gateway, and the gateway's signed + * response is verified and returned, through a flow `connector_action`. + * 2. A response altered after the gateway signed it fails the dispatch, and + * its body never reaches the flow; so does an unsigned 2xx. + * 3. The private key appears in no request and no connector descriptor. + */ + +import { createSign, createVerify, generateKeyPairSync, randomUUID, type KeyObject } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation'; +import { ConnectorOpenApiPlugin } from './connector-openapi-plugin.js'; + +/** The `alipay.trade.query` operation as Alipay's v3 OpenAPI document declares it, schemas trimmed. */ +const alipayV3Document = { + openapi: '3.0.3', + info: { title: '支付宝开放平台API', version: '2026-09-23' }, + servers: [{ url: 'https://openapi.alipay.com' }], + paths: { + '/v3/alipay/trade/query': { + post: { + operationId: 'alipay.trade.query', + summary: '统一收单交易查询', + requestBody: { + required: false, + content: { 'application/json': { schema: { type: 'object', properties: { out_trade_no: { type: 'string' }, trade_no: { type: 'string' } } } } }, + }, + responses: { '200': { description: 'common response' } }, + }, + }, + }, +}; + +/** A key pair as Alipay's console hands them out: bare base64, PKCS#8 and SPKI. */ +function keyPair() { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + return { + privateKey, + publicKey, + privateText: privateKey.export({ format: 'der', type: 'pkcs8' }).toString('base64'), + publicText: publicKey.export({ format: 'der', type: 'spki' }).toString('base64'), + }; +} +const app = keyPair(); +const alipay = keyPair(); + +const TRADE = '{"trade_no":"2013112011001004330000121536","out_trade_no":"20150320010101001","trade_status":"TRADE_SUCCESS"}'; + +type Mode = 'signed' | 'tampered' | 'unsigned'; +type Seen = { url: string; headers: Record; requestVerified: boolean }; + +/** The local Alipay gateway: verifies the request signature, answers signed (or, by `mode`, not). */ +function alipayGateway(appPublicKey: KeyObject, alipayPrivateKey: KeyObject, mode: { current: Mode }) { + const seen: Seen[] = []; + const impl = (async (url: string, init: RequestInit) => { + const headers = Object.fromEntries(Object.entries(init.headers as Record).map(([k, v]) => [k.toLowerCase(), v])); + const target = new URL(url); + const auth = /^ALIPAY-SHA256withRSA (app_id=[^,]+,nonce=[^,]+,timestamp=\d+),sign=(.+)$/.exec(headers.authorization ?? ''); + const content = auth ? `${auth[1]}\n${init.method}\n${target.pathname}${target.search}\n${(init.body as string | undefined) ?? ''}\n` : ''; + const requestVerified = auth !== null && createVerify('RSA-SHA256').update(content, 'utf8').verify(appPublicKey, auth[2]!, 'base64'); + seen.push({ url, headers, requestVerified }); + if (!requestVerified) { + return new Response('{"code":"invalid-signature","message":"验签出错"}', { status: 401, headers: { 'content-type': 'application/json' } }); + } + const timestamp = String(Date.now()); + const nonce = randomUUID(); + const signature = createSign('RSA-SHA256').update(`${timestamp}\n${nonce}\n${TRADE}\n`, 'utf8').sign(alipayPrivateKey, 'base64'); + const body = mode.current === 'tampered' ? TRADE.replace('TRADE_SUCCESS', 'TRADE_CLOSED') : TRADE; + const signedHeaders = mode.current === 'unsigned' + ? {} + : { 'alipay-timestamp': timestamp, 'alipay-nonce': nonce, 'alipay-signature': signature }; + return new Response(body, { status: 200, headers: { 'content-type': 'application/json', ...signedHeaders } }); + }) as unknown as typeof fetch; + return { impl, seen }; +} + +async function boot(mode: { current: Mode }) { + const gateway = alipayGateway(app.publicKey, alipay.privateKey, mode); + const declared = [{ + name: 'alipay_pay', + label: 'Alipay', + type: 'api', + provider: 'openapi', + providerConfig: { spec: alipayV3Document }, + auth: { + type: 'signed', + scheme: 'alipay-v3', + keyId: '2021000000000000', + credentialRef: 'ALIPAY_APP_PRIVATE_KEY_26710', + vendorPublicKeyRef: 'ALIPAY_PUBLIC_KEY_26710', + }, + }]; + const references: Record = { ALIPAY_APP_PRIVATE_KEY_26710: app.privateText, ALIPAY_PUBLIC_KEY_26710: alipay.publicText }; + const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); + kernel.use(new AutomationServicePlugin({ credentialResolver: (ref) => references[ref] })); + kernel.use(new ConnectorOpenApiPlugin({ fetchImpl: gateway.impl })); + kernel.use({ + name: 'test.metadata', + type: 'standard', + version: '1.0.0', + dependencies: [], + async init(ctx: { registerService(name: string, service: unknown): void }) { + ctx.registerService('objectql', { registry: { listItems: (t: string) => (t === 'connector' ? declared : []) } }); + }, + async start() {}, + } as never); + await kernel.bootstrap(); + return { engine: kernel.getService('automation'), seen: gateway.seen, kernel }; +} + +async function queryTradeByFlow(engine: AutomationEngine, flow: string) { + engine.registerFlow(flow, { + name: flow, label: flow, type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'query', type: 'connector_action', label: 'Query trade', + connectorConfig: { connectorId: 'alipay_pay', actionId: 'alipay.trade.query', input: { body: { out_trade_no: '20150320010101001' } } }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'query' }, { id: 'e2', source: 'query', target: 'end' }], + } as never); + return engine.execute(flow, {} as never); +} + +describe('#26710 acceptance — an openapi instance over the Alipay v3 document, signed and verified end to end', () => { + it('1. the gateway accepts the signed request, and its signed response is verified and returned', async () => { + const { engine, seen, kernel } = await boot({ current: 'signed' }); + try { + expect(engine.getConnectorDegradedReason('alipay_pay')).toBeUndefined(); + const result = await queryTradeByFlow(engine, 'query_trade'); + expect(result.success, result.error).toBe(true); + expect(seen).toHaveLength(1); + expect(seen[0]!.requestVerified).toBe(true); + expect(seen[0]!.url).toBe('https://openapi.alipay.com/v3/alipay/trade/query'); + expect(seen[0]!.headers.authorization).toMatch(/^ALIPAY-SHA256withRSA app_id=2021000000000000,nonce=[0-9a-f-]{36},timestamp=\d{13},sign=/); + + const query = engine.resolveConnectorAction('alipay_pay', 'alipay.trade.query')!; + expect(await query({ body: { out_trade_no: '20150320010101001' } }, {} as never)).toEqual({ + status: 200, ok: true, body: JSON.parse(TRADE), + }); + expect(seen[1]!.requestVerified).toBe(true); + // Every call is signed afresh. + expect(seen[1]!.headers.authorization).not.toBe(seen[0]!.headers.authorization); + } finally { + await kernel.shutdown().catch(() => {}); + } + }); + + it('2. a response altered after Alipay signed it, or an unsigned 2xx, fails the dispatch and its body never reaches the flow', async () => { + const mode: { current: Mode } = { current: 'tampered' }; + const { engine, seen, kernel } = await boot(mode); + try { + const tampered = await queryTradeByFlow(engine, 'query_tampered'); + expect(tampered.success).toBe(false); + expect(tampered.error).toContain('does not verify against the public key its vendorPublicKeyRef names'); + expect(JSON.stringify(tampered)).not.toContain('TRADE_CLOSED'); + expect(seen[0]!.requestVerified).toBe(true); + + mode.current = 'unsigned'; + const unsigned = await queryTradeByFlow(engine, 'query_unsigned'); + expect(unsigned.success).toBe(false); + expect(unsigned.error).toContain('carries no complete Alipay signature'); + expect(JSON.stringify(unsigned)).not.toContain('TRADE_SUCCESS'); + + // CONTROL: the same instance, answered signed, succeeds. + mode.current = 'signed'; + expect((await queryTradeByFlow(engine, 'query_signed')).success).toBe(true); + } finally { + await kernel.shutdown().catch(() => {}); + } + }); + + it('3. the private key is in no request and no connector descriptor', async () => { + const { engine, seen, kernel } = await boot({ current: 'signed' }); + try { + await queryTradeByFlow(engine, 'query_trade'); + const fragment = app.privateText.slice(64, 160); + expect(JSON.stringify(seen)).not.toContain(fragment); + expect(JSON.stringify(engine.getConnectorDescriptors())).not.toContain(fragment); + } finally { + await kernel.shutdown().catch(() => {}); + } + }); +}); From e8422d9e0fc35f4ae3cd9e7681d7b612cd5a5981 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 07:20:07 +0000 Subject: [PATCH 4/8] docs(connectors): the alipay-v3 scheme; ADR-0143 D2.3 and acceptance amendments; the changeset Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cxRP1AqTeP2PWAhwGK --- .../26710-signed-connector-auth-alipay.md | 31 +++++++++++++ content/docs/automation/connectors.mdx | 46 +++++++++++++++---- ...43-outbound-connector-auth-is-open-tier.md | 34 ++++++++++++++ 3 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 .changeset/26710-signed-connector-auth-alipay.md diff --git a/.changeset/26710-signed-connector-auth-alipay.md b/.changeset/26710-signed-connector-auth-alipay.md new file mode 100644 index 00000000000..f280ff4c85b --- /dev/null +++ b/.changeset/26710-signed-connector-auth-alipay.md @@ -0,0 +1,31 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-automation': minor +--- + +feat(spec): `alipay-v3` joins the `signed` connector-instance auth schemes, with response verification (ADR-0143 D2.3, #26710) + +Clause-②: yes (widening) +Metadata-protocol: added + + + +A declarative connector instance can now declare Alipay's OpenAPI v3 signature as its signing scheme. Its responses are verified too. It joins the Alibaba Cloud and Tencent Cloud schemes. + +- **`@objectstack/spec`** + - `ConnectorSigningSchemeSchema` gains `alipay-v3`. + - The `signed` union gains its closed shape: `{ type: 'signed', scheme: 'alipay-v3', keyId, credentialRef, vendorPublicKeyRef }`. + - `keyId` is the app id. + - `credentialRef` names the application private key. + - `vendorPublicKeyRef` names the Alipay public key that every response is verified with. It is required. + - The same publish refusals apply as for the other schemes: inline key spellings by name, an authored `signature`, and `signed` on `provider: 'mcp'`. Also refused: + - `appPrivateKey`, by name; + - `alipayPublicKey`, which is answered with `vendorPublicKeyRef`. + + `appId` is answered with `keyId`. An unknown scheme's refusal now lists all three schemes. +- **`@objectstack/service-automation`** + - Every request is signed `ALIPAY-SHA256withRSA` with a fresh nonce and timestamp. + - Every response below status 400 must carry a valid Alipay signature, an empty body included. A signed error response is verified too. A response that fails either check fails the dispatch, and its body is never returned. + - The vendor public key resolves through the same chain as `credentialRef`. An instance whose public key is missing is degraded with that reference named. The credential door lists both references against the instance. + - A key that is not an RSA key fails boot, naming which reference, never the value. + - The keys never leave the host. diff --git a/content/docs/automation/connectors.mdx b/content/docs/automation/connectors.mdx index 7bebaa796de..a5c83f59d92 100644 --- a/content/docs/automation/connectors.mdx +++ b/content/docs/automation/connectors.mdx @@ -484,7 +484,9 @@ Some upstreams take no token at all: every request carries a signature over its own method, path, query, headers and body, computed with a key the caller never sends. The `signed` kind declares that (ADR-0143 D2.3). The metadata names the **scheme** and the public **key id**; the signing key itself is a -`credentialRef`, resolved through the same chain as every other kind. +`credentialRef`, resolved through the same chain as every other kind. A scheme +whose vendor signs its responses also names the vendor's public key, as a +second reference. ```ts // Alibaba Cloud OpenAPI, V3 signature (ACS3-HMAC-SHA256): @@ -502,8 +504,22 @@ auth: { keyId: 'YourSecretId', // the SecretId: public, stays in metadata credentialRef: 'TENCENT_SECRET_KEY', } + +// Alipay OpenAPI v3 (ALIPAY-SHA256withRSA), responses verified: +auth: { + type: 'signed', + scheme: 'alipay-v3', + keyId: '2021000000000000', // the app id: public, stays in metadata + credentialRef: 'ALIPAY_APP_PRIVATE_KEY', // the application private key (RSA) + vendorPublicKeyRef: 'ALIPAY_PUBLIC_KEY', // the Alipay public key responses are verified with +} ``` +Both of an `alipay-v3` instance's references are listed against it by the +credential door, and setting or replacing either one re-materializes it. The +keys are accepted as Alipay's console issues them (the bare base64 body) or as +PEM. + What the platform does with it: - **Every request is signed, each attempt fresh.** A retry is signed again with @@ -515,7 +531,8 @@ What the platform does with it: (`external.connector` is refused with `422`): a driver cannot sign per request. - **Schemes that sign their responses are verified** before a result is - returned; a response that does not verify fails the dispatch. + returned; a response that does not verify fails the dispatch, and its body + never reaches the flow. - **`aliyun-acs3-hmac-sha256`** signs `host`, `content-type` and every `x-acs-*` header, and sets `x-acs-date`, `x-acs-signature-nonce`, `x-acs-content-sha256` and `Authorization`. The API's own action and version @@ -526,9 +543,16 @@ What the platform does with it: 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. + `X-TC-Region` are yours to send and are not signed. A request without a + content type is sent with `Content-Type: application/x-www-form-urlencoded`, + the type it is signed with. +- **`alipay-v3`** signs the method, the path and query as sent and the body, + with a fresh nonce and millisecond timestamp, and sets `Authorization: + ALIPAY-SHA256withRSA app_id=…,nonce=…,timestamp=…,sign=…`. Every response + below status 400 must carry a valid `alipay-signature` (an empty body too), + checked against the key `vendorPublicKeyRef` names; an error response is + verified when it is signed, and an unsigned one fails as the error it is. + Public-key mode only: Alipay's certificate mode is not supported. What the publish door refuses: @@ -537,9 +561,11 @@ 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`. 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. + is refused by name, pointing at `credentialRef` (on `alipay-v3`, + `appPrivateKey` too, and `alipayPublicKey` points at `vendorPublicKeyRef`). + The vendors' words for the key id (`accessKeyId`, `secretId`, `appId`) 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'`. @@ -601,7 +627,9 @@ retry): | Invalid `providerConfig` (missing `baseUrl` / `spec` / `transport`, wrong shape) | **Hard boot error** from the provider factory | | `auth` is a grant-bearing kind (`oauth2`, `app-credential`) on a server that does not yet run its per-dispatch resolution | **Not fatal**: the instance registers **degraded**, naming the kind, and the upstream is never called without its credential; a datasource borrowing it is refused with `422` | | `auth` is `signed` on a provider that does not say it applies the request signer | **Hard boot error** naming the instance, the scheme and the provider: its calls would go out unsigned — declare it through `rest` or `openapi` | -| `credentialRef` resolves to nothing | **Not fatal**: the instance registers **degraded**, naming the reference — set it (`PUT …/credential-refs/:ref`), and it materializes without a restart | +| `credentialRef` (or a `signed` instance's `vendorPublicKeyRef`) resolves to nothing | **Not fatal**: the instance registers **degraded**, naming the reference — set it (`PUT …/credential-refs/:ref`), and it materializes without a restart | +| A `signed` reference resolves to something that is not a key of the scheme's kind (an `alipay-v3` key that is not RSA) | **Hard boot error** naming the instance and which reference cannot sign or verify, never the value | +| A response to an `alipay-v3` call is unsigned (below status 400) or its signature does not verify | The dispatch **fails** naming the instance and the status; the response's body is never returned | | The upstream refuses the credential (`401` / `403`) | **Not fatal**: the instance registers **degraded**. A stored credential is marked `rejected` and waits until it is set again (or a restart); an environment or vault credential is retried on the backoff | | Instance `name` collides with a plugin-registered connector | **Hard boot error** — there is no silent precedence between the two worlds | | stdio transport without host opt-in | **Hard boot error** with the `declarativeStdio` fix inline — a security rejection is never retried | diff --git a/docs/adr/0143-outbound-connector-auth-is-open-tier.md b/docs/adr/0143-outbound-connector-auth-is-open-tier.md index 615208b908e..f69675f2df6 100644 --- a/docs/adr/0143-outbound-connector-auth-is-open-tier.md +++ b/docs/adr/0143-outbound-connector-auth-is-open-tier.md @@ -179,6 +179,34 @@ carry and forbid; exact key spelling is the spec slice's, reviewed against this (Q4). A "generic HMAC" member is admitted only with a canonical form pinned in the spec; without one, it is a dialect. +> **Amended (2026-10-31, #26710) — Alipay's vectors, and which responses it verifies.** The maintainer +> ruled on 2026-10-31, verbatim: 「其他同意」 (accepting option B of the director decision on S3's test +> vectors) and 「算」 (the local end-to-end test counts as the acceptance below). +> +> - **A third branch of the vector rule.** A vendor may publish neither a complete worked example nor +> a byte-exact vector in an official SDK test suite. The vectors are then produced by running two of +> the vendor's official SDKs over one placeholder key that belongs to no account, and they are +> admitted only when the two agree byte for byte. The test names both SDKs and their versions, and the +> generator and the placeholder key are kept in the repository, so anyone can re-run the check. RSA +> PKCS#1 v1.5 signatures are deterministic, so agreement is exact, not approximate. +> - **Alipay takes that branch.** On 2026-10-31 no official v3 SDK test suite carried a byte-exact +> vector (Java v3, PHP v3, .NET v3 and the Node SDK were checked). The vendor's signature pages could +> not be reached to look for a worked example with a usable key, and the maintainer ruled that this is +> not a gate. `alipay-v3`'s vectors come from `com.alipay.sdk:alipay-sdk-java-v3:3.1.89.ALL` and +> `alipay-sdk` 4.14.0 (Node); the generator is +> `packages/services/service-automation/test-vectors/alipay-v3/`. +> - **Which responses `alipay-v3` verifies is a deliberate choice, because the two SDKs disagree.** +> Java v3 3.1.89 verifies a 2xx response unless its body is empty, and verifies an error response +> only when it carries `alipay-signature`. Node 4.14.0 verifies every response below status 400, +> including an empty body, and never verifies an error response. The platform takes the stricter rule +> in each case: every response below 400 must carry a valid signature, an empty body included, and an +> error response is verified when it is signed. An unsigned error response fails as the error it +> already is. The rule is one function in the signer (`alipayV3ResponseMustVerify`), so a different +> ruling changes one line. +> - The vendor public key is the instance's `vendorPublicKeyRef`, the "vendor public-key +> `credentialRef`" this decision names. It resolves through the same chain as `credentialRef`, and +> an instance whose public key does not resolve is degraded, not served unverified. + ### D3 — Secrets are references; tokens are runtime state in a connection record - Every secret an author names is a `credentialRef`, resolved through `CredentialResolver` (ADR-0097 §3, @@ -418,6 +446,12 @@ platform public key rather than rotating certificates, but the response-verifica - An `openapi` instance over the Alipay v3 document, normalized to JSON first, with `auth: { type: 'signed', scheme: 'alipay-v3', … }`, dispatches a sandbox query over https and verifies the response signature. Signer unit tests meet D2.3's vector rule. + > **Amended (2026-10-31, #26710).** The maintainer ruled 「算」: a local end-to-end test counts as this + > line, and no live sandbox call is owed. In that test, an `openapi` instance over the vendor's + > `alipay.trade.query` operation dispatches through a flow `connector_action` to a local stand-in for + > the gateway. The stand-in checks the request signature and signs its answer, and a tampered or + > unsigned answer fails the dispatch. The test is + > `packages/connectors/connector-openapi/src/alipay-v3-acceptance.test.ts`. - `client_credentials` and the dynamic-registration flow pass a contract test against the embedded OAuth authorization server. - An unknown `grant`, `vendor` or `scheme`, or an inline secret on any variant, is refused at publish From 13787d0c9489ec55a19d2ebb1b1c7911bf9eb7e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 14:44:30 +0000 Subject: [PATCH 5/8] chore(spec): regenerate the reference index from the merged tree Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- content/docs/references/index.mdx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index c450df06baa..72d0b790b98 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 — 1571 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1572 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/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 33 | 451 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 34 | 452 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [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. | @@ -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** | **1571** | 14 protocol modules | +| **Total** | **204** | **1572** | 14 protocol modules | --- @@ -63,13 +63,14 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **33 pages, 451 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **34 pages, 452 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. | File | Schemas | | :--- | :--- | | [`analytics.zod.ts`](/docs/references/api/analytics) | `AnalyticsEndpoint`, `AnalyticsMetadataResponse`, `AnalyticsQueryRequest`, `AnalyticsResultResponse`, `AnalyticsSqlResponse`, `DatasetCompareTo`, `DatasetSelection`, `DatasetTotals`, `GetAnalyticsMetaRequest` | +| [`app-bundle.zod.ts`](/docs/references/api/app-bundle) | `AppMetadataBundleResponse` | | [`auth.zod.ts`](/docs/references/api/auth) | `AuthProvider`, `LoginRequest`, `LoginType`, `RefreshTokenRequest`, `RegisterRequest`, `Session`, `SessionResponse`, `SessionUser`, `UserProfileResponse` | | [`auth-endpoints.zod.ts`](/docs/references/api/auth-endpoints) | `AuthEndpoint`, `AuthFeaturesConfig`, `AuthProviderInfo`, `DeviceRequestResponse`, `DeviceTokenResponse`, `EmailPasswordConfigPublic`, `GetAuthConfigResponse` | | [`automation-api.zod.ts`](/docs/references/api/automation-api) | `AutomationApiErrorCode`, `AutomationFlowPathParams`, `AutomationRunPathParams`, `CreateFlowRequest`, `CreateFlowResponse`, `DeleteFlowRequest`, `DeleteFlowResponse`, `FlowFailureDetails`, `FlowInnerRefusal`, `GetFlowRequest`, `GetFlowResponse`, `GetRunRequest`, `GetRunResponse`, `ListRunsRequest`, `ListRunsResponse`, `ResumeFailureDetails`, `ToggleFlowRequest`, `ToggleFlowResponse`, `TriggerFlowRequest`, `TriggerFlowResponse`, `UpdateFlowRequest`, `UpdateFlowResponse` | From 10c6ddf7b3bdc7f1ee73dbb64acff60903120968 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 16:53:48 +0000 Subject: [PATCH 6/8] docs(skills): the automation recipe lists alipay-v3 among the signed schemes; only WeChat Pay stays unsigned Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .../references/integration-recipes.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/skills/objectstack-automation/references/integration-recipes.md b/skills/objectstack-automation/references/integration-recipes.md index e545399824e..65c3fbcc24b 100644 --- a/skills/objectstack-automation/references/integration-recipes.md +++ b/skills/objectstack-automation/references/integration-recipes.md @@ -30,8 +30,8 @@ deployment and to the sender. Scaffolded apps list the three executors in - `auth`, each variant with a `credentialRef`: `bearer`; `api-key` (`headerName`, default `X-API-Key`, or `paramName` for a query-string key); `basic` (`username` stays in metadata); `signed` on `openapi` / `rest` (`scheme` - `aliyun-acs3-hmac-sha256` or `tencentcloud-tc3-hmac-sha256`, `keyId`) signs each - request. A secret in a url path has no variant — use a + `aliyun-acs3-hmac-sha256`, `tencentcloud-tc3-hmac-sha256` or `alipay-v3`, + `keyId`) signs each request. A secret in a url path has no variant — use a token-authenticated connector. `authentication: { token }` is refused at `os validate`. - `defineConnector`: `@objectstack/spec/integration`; `defineFlow`: `@objectstack/spec`; both register in `defineStack`. @@ -154,5 +154,5 @@ Each `operationId` is a named action in the flow palette and to agents; (`feishu` / `dingtalk` / `wecom`), but no server runs the grant yet: the instance is held degraded, calling no upstream. Do not park a short-lived token behind a `bearer` ref. Blocked on the outbound-OAuth card. -- **RSA-signing vendor** (Alipay, WeChat Pay): no `signed` scheme signs RSA yet; - blocked on the request-signing card. +- **WeChat Pay** (RSA-signed APIv3): no `signed` scheme signs it yet; Alipay is + `alipay-v3` (requests signed, responses verified against `vendorPublicKeyRef`). From 1083e854310c848117451ea82572e650af10f393 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 16:55:40 +0000 Subject: [PATCH 7/8] docs(skills): keep the recipe within its token ceiling; the WeChat Pay line names Alipay's scheme in fewer words Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .../objectstack-automation/references/integration-recipes.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/objectstack-automation/references/integration-recipes.md b/skills/objectstack-automation/references/integration-recipes.md index 65c3fbcc24b..37f15a200da 100644 --- a/skills/objectstack-automation/references/integration-recipes.md +++ b/skills/objectstack-automation/references/integration-recipes.md @@ -154,5 +154,5 @@ Each `operationId` is a named action in the flow palette and to agents; (`feishu` / `dingtalk` / `wecom`), but no server runs the grant yet: the instance is held degraded, calling no upstream. Do not park a short-lived token behind a `bearer` ref. Blocked on the outbound-OAuth card. -- **WeChat Pay** (RSA-signed APIv3): no `signed` scheme signs it yet; Alipay is - `alipay-v3` (requests signed, responses verified against `vendorPublicKeyRef`). +- **WeChat Pay**: no `signed` scheme yet (Alipay's is `alipay-v3`, which also + verifies responses). From 2686771adc8afea173a408db70aad51304f29595 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 17:00:45 +0000 Subject: [PATCH 8/8] chore(docs): regenerate the references index after merging main Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- content/docs/references/index.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 72d0b790b98..b525c5ee011 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 — 1572 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1574 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/. */} @@ -33,8 +33,8 @@ counts are sums of the rows they head. Regenerate with | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [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** | **204** | **1572** | 14 protocol modules | +| [UI Protocol](/docs/references/ui) | 16 | 174 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | +| **Total** | **204** | **1574** | 14 protocol modules | --- @@ -371,7 +371,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a ## UI Protocol -**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 172 schemas** +**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 174 schemas** Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. @@ -392,7 +392,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay | [`report.zod.ts`](/docs/references/ui/report) | `JoinedReportBlock`, `Report`, `ReportChart`, `ReportSort`, `ReportType` | | [`responsive.zod.ts`](/docs/references/ui/responsive) | `ResponsiveStyles`, `StyleMap` | | [`sharing.zod.ts`](/docs/references/ui/sharing) | `SharingConfig` | -| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `EmptyState`, `FormButtonConfig`, `FormField`, `FormSection`, `FormSelectOption`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListMapConfig`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` | +| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `EmptyState`, `FormButtonConfig`, `FormField`, `FormSection`, `FormSelectOption`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListMapConfig`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterElement`, `ViewFilterOrGroup`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` | ---