diff --git a/docs/api-key-signing.md b/docs/api-key-signing.md new file mode 100644 index 0000000..14c0f6d --- /dev/null +++ b/docs/api-key-signing.md @@ -0,0 +1,341 @@ +# API Key Request Signing + +Integration guide for client applications and SDKs authenticating to Apex +with a workspace or platform API key (as opposed to a user session/bearer +token). Covers both supported signing schemes: **HMAC-SHA256** (secret-based) +and **Ed25519** (public-key based). + +## Using this from the SDK + +If you're using `@fystack/sdk`, you don't need to implement anything below — +the SDK builds the canonical string, timestamps the request, and signs it for +you. Just pass the right credential shape: + +```typescript +// HMAC-SHA256 scheme +new FystackSDK({ credentials: { apiKey: '...', apiSecret: '...' } }) + +// Ed25519 scheme — PEM PKCS8 private key, matches the public_key registered on the API key +import { LocalPrivateKeySigner } from '@fystack/sdk' +new FystackSDK({ + credentials: { + apiKey: '...', + signer: new LocalPrivateKeySigner('-----BEGIN PRIVATE KEY-----\n...') + } +}) + +// Ed25519 scheme — key held in AWS KMS, never leaves KMS. +// `keyId` accepts a key ID, alias, or full ARN. +import { AwsKmsSigner } from '@fystack/sdk' + +// Production, running on EC2/ECS/Lambda with an IAM role attached: omit +// `credentials` from clientConfig entirely and the underlying KMSClient +// picks up the injected role via its default credential provider chain. +new FystackSDK({ + credentials: { + apiKey: '...', + signer: new AwsKmsSigner({ + keyId: 'arn:aws:kms:ap-southeast-1:123456789012:key/1234abcd-...', + clientConfig: { region: 'ap-southeast-1' } + }) + } +}) + +// Local dev against LocalStack/minstack: point `endpoint` at the emulator +// and pass any placeholder static credentials it accepts. +new FystackSDK({ + credentials: { + apiKey: '...', + signer: new AwsKmsSigner({ + keyId: 'alias/signer', + clientConfig: { + region: 'ap-southeast-1', + endpoint: 'http://localhost:4566', + credentials: { accessKeyId: 'test', secretAccessKey: 'test' } + } + }) + } +}) + +// Or bring your own pre-configured client (e.g. one built with +// `fromTemporaryCredentials`/`AssumeRoleCommand`, or shared/reused +// elsewhere in your app) via the `client` option instead of `clientConfig`: +import { KMSClient } from '@aws-sdk/client-kms' +new FystackSDK({ + credentials: { + apiKey: '...', + signer: new AwsKmsSigner({ + keyId: 'alias/signer', + client: new KMSClient({ region: 'ap-southeast-1' }) + }) + } +}) +``` + +`AwsKmsSignerOptions` (`src/requestSigner.ts`): + +| Option | Required | Notes | +| -------------- | -------- | ------------------------------------------------------------------------------------------------ | +| `keyId` | yes | Key ID, alias (`alias/...`), or full ARN. | +| `client` | no* | A pre-configured `KMSClient`-like instance. Provide this **or** `clientConfig`, not both. | +| `clientConfig` | no* | Passed to `new KMSClient(...)`. Lazily requires `@aws-sdk/client-kms`. Omit `credentials` here to fall back to the default provider chain (IAM role injection); set `endpoint` + static `credentials` for LocalStack/minstack. | + +\* If neither is given, `KMSClient` is constructed with `{}`, relying entirely on ambient AWS config/environment. + +Implementation: `computeHMAC` / `signEd25519Request` in +`src/utils.ts` build and sign the canonical string; `LocalPrivateKeySigner` +and `AwsKmsSigner` in `src/requestSigner.ts` implement the `RequestSigner` +interface used for Ed25519 signing; `composeAPIHeaders` in `src/api.ts` +picks a scheme based on which credential field is set (`signer` → Ed25519, +`apiSecret` → HMAC) and attaches the `ACCESS-API-KEY` / `ACCESS-TIMESTAMP` / +`ACCESS-SIGN` headers. The rest of this document describes that wire +protocol in full, for non-SDK integrators and for reference. + +## Headers + +Every signed request must carry these three headers: + +| Header | Type | Notes | +| ------------------ | ------------------- | ----------------------------------------------------------| +| `ACCESS-API-KEY` | string (uuid4) | The API key ID (`api_key` field returned at creation). | +| `ACCESS-TIMESTAMP` | string (unsigned int) | Unix timestamp in **seconds** at signing time. | +| `ACCESS-SIGN` | string (base64) | The signature — encoding differs by scheme, see below. | + +`ACCESS-TIMESTAMP` must be within **5 minutes** of server time (past or +future) or the request is rejected with `Invalid access timestamp`. Sign and +send the request promptly — don't precompute far ahead of time. + +## Canonical string to sign + +Both schemes sign the same message, built from the request: + +``` +method=&path=×tamp=&body= +``` + +- `HTTP_METHOD` — uppercase, e.g. `GET`, `POST`, `PATCH`, `DELETE`. +- `HTTP_PATH` — the request path **including the `/api/v1` prefix**, no + query string, exactly as sent (e.g. `/api/v1/workspaces/{workspaceId}/invite`). +- `timestamp` — the same integer value sent in `ACCESS-TIMESTAMP`. +- `body` — the **exact raw request body bytes** as a string. For a `GET` + request (or any request with no body), use an empty string. Do not + re-serialize/reformat JSON before signing — sign the exact bytes you will + transmit, since the server signs against the exact bytes it receives. + +Example, for `POST /api/v1/workspaces/abc-123/invite` at `timestamp=1735689600` +with body `{"email":"a@b.com"}`: + +``` +method=POST&path=/api/v1/workspaces/abc-123/invite×tamp=1735689600&body={"email":"a@b.com"} +``` + +## Scheme 1: HMAC-SHA256 (secret key) + +Used when the API key was created without a `public_key` (the default). The +plaintext secret is only ever shown once, in the creation response +(`api_secret`), as a **hex-encoded string** — store it as-is. + +**Signing steps:** + +1. Build the canonical string (above). +2. Compute `HMAC-SHA256(key = api_secret_string_bytes, message = canonical_string_bytes)`. + The key is the literal hex-string characters from `api_secret` (UTF-8 + bytes), **not** the hex-decoded raw bytes. +3. Hex-encode the resulting digest (lowercase hex string). +4. Base64-encode that hex string. This is the `ACCESS-SIGN` header value. + +``` +ACCESS-SIGN = base64( hex( HMAC_SHA256(key=api_secret, msg=canonical_string) ) ) +``` + +Pseudocode: + +```js +const digest = hmacSha256(apiSecret, canonicalString); // raw bytes +const hexDigest = toHex(digest); // lowercase hex string +const accessSign = base64Encode(utf8Bytes(hexDigest)); // ACCESS-SIGN header +``` + +## Scheme 2: Ed25519 (public key) + +Used when the API key was created with a `public_key` (PEM, PKIX, +`-----BEGIN PUBLIC KEY-----`). The corresponding private key never leaves +the client — only the public key is registered with Apex. + +In the SDK, wrap this private key (PEM PKCS8 format) in a `LocalPrivateKeySigner` +(or use `AwsKmsSigner` to keep the key in KMS) and pass it as `signer` on +`APICredentials` — see [Using this from the SDK](#using-this-from-the-sdk). + +**Signing steps:** + +1. Build the canonical string (above). +2. Sign the canonical string bytes directly with the Ed25519 private key + (64-byte signature, no hashing/pre-digest — Ed25519 does this internally). +3. Base64-encode the raw signature bytes. This is the `ACCESS-SIGN` header + value (no hex step, unlike HMAC). + +``` +ACCESS-SIGN = base64( Ed25519_Sign(privateKey, canonical_string) ) +``` + +### Key rotation (Ed25519 only) + +To rotate an Ed25519 key without downtime: + +1. Generate a new keypair. +2. `PATCH` the API key with `new_public_key` set to the new PEM public key + (`internal/api/workspace/service.go: UpdateAPIKey`). The key now accepts + signatures from **either** the old or new key. +3. Switch the client to sign with the new private key. +4. On the first request successfully verified with the new key, the server + auto-promotes it: `public_key` becomes the new key and `new_public_key` + is cleared. The old key stops working after that point. + +HMAC keys cannot be rotated this way — `new_public_key` is rejected with +`new_public_key requires an Ed25519 key; this key uses HMAC` if the API key +uses a secret. + +## Additional checks enforced server-side + +- **IP allowlist**: if the API key has `allowed_ips` configured, the request + is rejected with `IP address not whitelisted` unless the caller's IP + matches an entry (single IP or CIDR). +- **Expiration**: keys default to a 90-day TTL if `expires_at` isn't set at + creation, capped at 365 days. An expired, disabled, or revoked key is + rejected. +- **Scope**: workspace-scoped keys (`scope_type: workspace`) can call any + in-scope route for the workspace; wallet-scoped keys + (`scope_type: wallet`, `scope_ids`) are restricted to specific wallets. + +## API endpoints + +All management endpoints require a **user session** (`Authorization: Bearer` +or `access_token` cookie) — an API key cannot create, list, update, or +delete other API keys. `workspaceId` and `apiKeyId` are path UUIDs. + +| Method | Path | Handler | Required permission | Notes | +| ------ | -------------------------------------------------- | -------------- | -------------------- | ----- | +| POST | `/api/v1/workspaces/{workspaceId}/api-key` | `CreateAPIKey` | `create_api_key` | Also requires `X-MFA-Token` header if the caller has MFA enabled (see below). | +| GET | `/api/v1/workspaces/{workspaceId}/api-keys` | `GetAllAPIKeys`| `view_api_key` | Lists keys for the workspace. | +| PATCH | `/api/v1/workspaces/{workspaceId}/api-key/{apiKeyId}` | `UpdateAPIKey` | `update_api_key` | Partial update; see fields below. | +| DELETE | `/api/v1/workspaces/{workspaceId}/api-key/{apiKeyId}` | `DeleteAPIKey` | `delete_api_key` | Immediate, irreversible revocation. | + +(Source: `internal/api/workspace/controller.go`, routes registered at +`RegisterRoutes`.) + +### MFA on create + +`POST .../api-key` is wrapped in `api.RequireMFAToken(..., +enum.MFAOperationCreateAPIKey)`. If the calling user has MFA enabled, the +request must carry a fresh `X-MFA-Token` header (obtained from the +account's MFA challenge flow) or it's rejected with `MFA token required` / +`Invalid or expired MFA token`. Users without MFA enabled skip this check. + +### Create — request body + +`POST /api/v1/workspaces/{workspaceId}/api-key` + +```json +{ + "name": "my-integration", + "role_id": "b1f0...", + "scope_type": "workspace", + "scope_ids": [], + "wallet_id": null, + "allowed_ips": ["203.0.113.10", "198.51.100.0/24"], + "expires_at": "2027-01-01T00:00:00Z", + "public_key": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" +} +``` + +| Field | Type | Notes | +| ------------- | ----------------- | --------------------------------------------------------------------| +| `name` | string, required | 3–100 chars, alphanumeric + spaces. | +| `role_id` | uuid, optional | Grants the key this role; omit to use the default (workspace admin). | +| `scope_type` | `workspace`\|`wallet`, optional | Defaults to `workspace`. | +| `scope_ids` | uuid[], optional | Wallet IDs; required (non-empty) when `scope_type` is `wallet`. | +| `wallet_id` | uuid, optional | Convenience alias — setting it implies `scope_type: wallet`. | +| `allowed_ips` | string[], optional | Up to 50 IPs/CIDRs. Empty/omitted = no IP restriction. | +| `expires_at` | RFC3339, optional | Future date, max 365 days out. Omit for the 90-day default. | +| `public_key` | string, optional | PEM Ed25519 public key. Omit to get an HMAC secret instead. | + +Response (HMAC case): + +```json +{ + "success": true, + "data": { + "name": "my-integration", + "api_key": "b8e2...-uuid", + "api_secret": "9f3a1c...hex-secret-shown-once" + } +} +``` + +Response (Ed25519 case): `api_secret` is omitted, `public_key` is echoed +back instead. **`api_secret` is never retrievable again after creation** — +if it's lost, delete the key and create a new one. + +### List + +`GET /api/v1/workspaces/{workspaceId}/api-keys` — no body/query params. +Returns an array of key metadata (never the secret): + +```json +{ + "success": true, + "data": [ + { + "id": "b8e2...-uuid", + "name": "my-integration", + "created_at": "2026-01-01T00:00:00Z", + "wallet": null, + "allowed_ips": ["203.0.113.10"], + "role": { "id": "b1f0...", "name": "admin" }, + "scope_type": "workspace", + "scope_ids": [], + "expires_at": "2027-01-01T00:00:00Z", + "last_used_at": "2026-07-10T08:00:00Z" + } + ] +} +``` + +### Update + +`PATCH /api/v1/workspaces/{workspaceId}/api-key/{apiKeyId}` + +```json +{ + "allowed_ips": ["203.0.113.10"], + "role_id": "b1f0...", + "scope_type": "wallet", + "scope_ids": ["c3d4...-uuid"], + "expires_at": "2027-06-01T00:00:00Z", + "new_public_key": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" +} +``` + +All fields are optional and independent — send only what changes. See +[Key rotation (Ed25519 only)](#key-rotation-ed25519-only) for +`new_public_key` semantics. Response is `{ "success": true, "data": null }` +on success. + +### Delete + +`DELETE /api/v1/workspaces/{workspaceId}/api-key/{apiKeyId}` — no body. +Revokes the key immediately; any in-flight signed request using it will +subsequently fail with `API key not found`. Response is +`{ "success": true, "data": null }`. + +## Key files (backend internals) + +| Layer | File | +| --------------------- | --------------------------------------------------------------------| +| Routes + handlers | `internal/api/workspace/controller.go` (`CreateAPIKey`, `GetAllAPIKeys`, `UpdateAPIKey`, `DeleteAPIKey`) | +| Request params | `internal/api/workspace/params.go` (`APIKeyParams`, `UpdateAPIKeyParams`) | +| Header binding | `internal/api/middleware.go` (`APIKeyHeaders`, `VerifyAPIKeyAccess`, `RequireMFAToken`) | +| Signature verification | `pkg/services/apikey/validator.go` (`ValidateRequest`, `constructSignString`, `ValidateAccessSign`) | +| Ed25519 primitives | `pkg/encryption/ed25519.go` | +| Secret encrypt/decrypt | `pkg/encryption/api_key.go` | +| Key creation/rotation | `internal/api/workspace/service.go` (`CreateAPIKey`, `UpdateAPIKey`) | diff --git a/package-lock.json b/package-lock.json index ba1f840..175e5b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@fystack/sdk", - "version": "0.1.2", + "version": "0.1.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@fystack/sdk", - "version": "0.1.2", + "version": "0.1.15", "license": "ISC", "dependencies": { "@solana/web3.js": "^1.98.0", @@ -18,6 +18,7 @@ "rpc-websockets": "^10.0.0" }, "devDependencies": { + "@aws-sdk/client-kms": "^3.1097.0", "@types/crypto-js": "^4.2.2", "bunchee": "^4.4.8", "vitest": "^1.2.2" @@ -28,6 +29,294 @@ "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.0.tgz", "integrity": "sha512-nA9XHtlAkYfJxY7bce8DcN7eKxWWCWkU+1GR9d+U6MbNpfwQp8TI7vqOsBsMcHoT4mBu2kypKoSKnghEzOOq5Q==" }, + "node_modules/@aws-sdk/client-kms": { + "version": "3.1097.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-kms/-/client-kms-3.1097.0.tgz", + "integrity": "sha512-mwPT8TFQ05Cmf7rNKCQ4K6pq/+sSTb++PYeZ4olGdjjb+aBaWrpflJMnmtmrHODs0GAwf5touQAEFA4jGEs8KA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/credential-provider-node": "^3.972.74", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.2.tgz", + "integrity": "sha512-8sT/M5vDcagx5/iM0Bfx7f6i3mfVOQkA34+GTMwp0lIWZb6ma+bjkzDS/r9yqU2yTPBqqMBFPT3+d9kUuuNDJA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.37", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.29.8", + "@smithy/signature-v4": "^5.6.9", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.63", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.63.tgz", + "integrity": "sha512-VSS9dftt7r7GiZ4gs8z0PNaMLVAaSj/MXVr6WQBtsrQQB9miJo7I6lQuJND1/ugFwK9x7OHCYZDkLSYh0FIZtA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.65", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.65.tgz", + "integrity": "sha512-SH/ec7p1J0CfC28+ypH38IwGENd7tQEvTpmuRSlinthiGxKlwzJbXGXxIMAhn0/lpxnIxudNmCsw3Cy0PDRoAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.8.tgz", + "integrity": "sha512-alkQpDUHsjHGVXvlV0XFXpPfh9+aTMmN6UYRky0Qky8SbvdxoQdDHftT4uugq8XShP6WtDQW7bo5YQ0SfNSxRQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/credential-provider-env": "^3.972.63", + "@aws-sdk/credential-provider-http": "^3.972.65", + "@aws-sdk/credential-provider-login": "^3.972.70", + "@aws-sdk/credential-provider-process": "^3.972.63", + "@aws-sdk/credential-provider-sso": "^3.973.7", + "@aws-sdk/credential-provider-web-identity": "^3.972.69", + "@aws-sdk/nested-clients": "^3.997.37", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/credential-provider-imds": "^4.4.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.70.tgz", + "integrity": "sha512-JlUjK6bYJAxN9PkWWCI/TiOYEdvXNKq61x2DTaEKxRMxAOYNk2LX8m4wVtDFxTZwyXx7Tpmxb49dNprkW/uqXQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/nested-clients": "^3.997.37", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.74.tgz", + "integrity": "sha512-V+7pzT0OzROL2uKcQ2+MpnfwKONvozYojmdn8RguAMX9o48gtSVvt+7aCkwWCH2thDXOnUPCN6qn4kiFDelZWA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.63", + "@aws-sdk/credential-provider-http": "^3.972.65", + "@aws-sdk/credential-provider-ini": "^3.973.8", + "@aws-sdk/credential-provider-process": "^3.972.63", + "@aws-sdk/credential-provider-sso": "^3.973.7", + "@aws-sdk/credential-provider-web-identity": "^3.972.69", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/credential-provider-imds": "^4.4.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.63", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.63.tgz", + "integrity": "sha512-lPt2oGMcvP3uPhhxX5EquHrzBI/ZgJce+CHKcOGZl2ZQAXLLSxu7k/Cgo0HIktyi9dmDFljbOkj4XAnXD93YVQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.7.tgz", + "integrity": "sha512-FR2b+7QNXP/q+eslVzrCjGKvso8Lcr/B18BvFyD2iLNhq42XSo+wnh8FfX6mtqgaVsL1vuB27uGXuY+xUTa7pg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/nested-clients": "^3.997.37", + "@aws-sdk/token-providers": "3.1097.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.69.tgz", + "integrity": "sha512-RWNTKGXRkzMJe8bgIAdlz9q0N97m7fThD9KOjBt2CSY+/xnIbrA1/Dnm/ZEz8ZeQ1Of5D+fLaPeoD3lGt6AU4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/nested-clients": "^3.997.37", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.37.tgz", + "integrity": "sha512-vfDmA6APjX1LWxvt6/zcAmTCgRXCj35M+bC9Ujmy40QxYs9Fa9bE7oblOB3ODZ4mdN9R5osU0hTzoJjJlQqqTg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/signature-v4-multi-region": "^3.996.42", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.42.tgz", + "integrity": "sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.9", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1097.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1097.0.tgz", + "integrity": "sha512-EIsdmy/f5IGc5r01RjKWNvrbBra6z0xudQM0D6Wf8DeGuPoRlubkLqr7VgWijFucO4kg0mtev9H3RX/ZOubUhg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/nested-clients": "^3.997.37", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", + "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", @@ -828,6 +1117,93 @@ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "dev": true }, + "node_modules/@smithy/core": { + "version": "3.31.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz", + "integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.16", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz", + "integrity": "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz", + "integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.9.13", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", + "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.6.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.12.tgz", + "integrity": "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@solana/buffer-layout": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@solana/buffer-layout/-/buffer-layout-4.0.1.tgz", @@ -1451,6 +1827,13 @@ "base-x": "^3.0.2" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", diff --git a/package.json b/package.json index 99fecb7..2e65b88 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fystack/sdk", - "version": "0.1.15", + "version": "0.1.16", "description": "Wallet SDK", "main": "dist/index.cjs", "types": "dist/types/index.d.ts", @@ -18,18 +18,27 @@ "author": "anhthii", "license": "ISC", "devDependencies": { + "@aws-sdk/client-kms": "^3.1097.0", "@types/crypto-js": "^4.2.2", "bunchee": "^4.4.8", "vitest": "^1.2.2" }, + "peerDependencies": { + "@aws-sdk/client-kms": "^3.0.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-kms": { + "optional": true + } + }, "dependencies": { - "@solana/web3.js": "^1.98.0", - "bs58": "^6.0.0", - "buffer": "^6.0.3", - "cross-fetch": "^4.0.0", - "crypto-js": "^4.2.0", - "ethers": "^6.10.0", - "rpc-websockets": "^10.0.0" + "@solana/web3.js": "1.98.0", + "bs58": "6.0.0", + "buffer": "6.0.3", + "cross-fetch": "4.0.0", + "crypto-js": "4.2.0", + "ethers": "6.10.0", + "rpc-websockets": "10.0.0" }, "directories": { "test": "test" diff --git a/readme.md b/readme.md index ac540fd..7b01357 100644 --- a/readme.md +++ b/readme.md @@ -45,6 +45,72 @@ const sdk = new FystackSDK({ > When `domain` is provided, it takes priority over `environment`. The SDK will connect to `https:///api/v1`. +### Authentication schemes + +The SDK signs every API request for you — you never need to build the +canonical string or compute a signature by hand. Pick whichever scheme your +API key was created with: + +```typescript +// HMAC-SHA256 (secret-based) — the default +const sdk = new FystackSDK({ + credentials: { apiKey: 'YOUR_API_KEY', apiSecret: 'YOUR_API_SECRET' }, + workspaceId: 'YOUR_WORKSPACE_ID' +}) + +// Ed25519 (public-key based) — sign with a local PEM key... +import { LocalPrivateKeySigner } from '@fystack/sdk' + +const sdk = new FystackSDK({ + credentials: { + apiKey: 'YOUR_API_KEY', + signer: new LocalPrivateKeySigner( + '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----' + ) + }, + workspaceId: 'YOUR_WORKSPACE_ID' +}) + +// ...or with AWS KMS — the private key never leaves KMS. +// `keyId` accepts a key ID, alias (e.g. 'alias/signer'), or full ARN. +import { AwsKmsSigner } from '@fystack/sdk' + +// On EC2/ECS/Lambda with an IAM role attached, just set the region — +// credentials are picked up from the injected role automatically. +const sdk = new FystackSDK({ + credentials: { + apiKey: 'YOUR_API_KEY', + signer: new AwsKmsSigner({ + keyId: 'alias/signer', + clientConfig: { region: 'ap-southeast-1' } + }) + }, + workspaceId: 'YOUR_WORKSPACE_ID' +}) + +// Local dev against LocalStack/minstack: override `endpoint` and pass +// placeholder static credentials. +const sdkLocal = new FystackSDK({ + credentials: { + apiKey: 'YOUR_API_KEY', + signer: new AwsKmsSigner({ + keyId: 'alias/signer', + clientConfig: { + region: 'ap-southeast-1', + endpoint: 'http://localhost:4566', + credentials: { accessKeyId: 'test', secretAccessKey: 'test' } + } + }) + }, + workspaceId: 'YOUR_WORKSPACE_ID' +}) +``` + +See [docs/api-key-signing.md](docs/api-key-signing.md#using-this-from-the-sdk) +for the full `AwsKmsSignerOptions` reference (including passing a +pre-configured `KMSClient` via the `client` option), the full signing +contract, and how to create an Ed25519-based API key. + ## Create Wallet Fystack supports two wallet types: diff --git a/src/api.ts b/src/api.ts index 10068e9..61c44f2 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,6 @@ import fetch from 'cross-fetch' import { APIConfig, Environment, createAPI } from './config' -import { computeHMAC, computeHMACForWebhook } from './utils' +import { computeHMAC, computeHMACForWebhook, buildCanonicalString } from './utils' import { APICredentials, WebhookEvent, @@ -79,8 +79,9 @@ async function composeAPIHeaders( body: Record = {}, headers?: Record ): Promise> { - if (!credentials.apiSecret || credentials.apiSecret === '') { - // If APISecret is not provided, use authToken + const hasSecret = credentials.apiSecret && credentials.apiSecret !== '' + if (!credentials.signer && !hasSecret) { + // Neither Ed25519 nor HMAC credentials provided, use authToken if (credentials.authToken) { return { Authorization: credentials.authToken @@ -100,12 +101,14 @@ async function composeAPIHeaders( body: Object.keys(body).length ? JSON.stringify(body) : '' } - const digest = await computeHMAC(credentials.apiSecret, params as Record) + const accessSign = credentials.signer + ? await credentials.signer.sign(buildCanonicalString(params as Record)) + : btoa(await computeHMAC(credentials.apiSecret!, params as Record)) const combinedHeaders = { 'ACCESS-API-KEY': credentials.apiKey, 'ACCESS-TIMESTAMP': String(currentTimestampInSeconds), - 'ACCESS-SIGN': btoa(digest), // convert to base64 + 'ACCESS-SIGN': accessSign, ...(headers ?? {}) } @@ -343,6 +346,9 @@ export class WebhookService { // Implement verify webhook here async verifyEvent(event: WebhookEvent, signature: string): Promise { + if (!this.credentials.apiSecret) { + throw new Error('apiSecret is required to verify webhook events') + } // Recompute HMAC const computedHMAC = await computeHMACForWebhook(this.credentials.apiSecret, event) const isValid = signature === computedHMAC diff --git a/src/index.ts b/src/index.ts index 6fd1c39..fbbdf67 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export * from './sdk' export * from './signer' +export * from './requestSigner' export * from './solanaSigner' export * from './api' export * from './types' diff --git a/src/requestSigner.ts b/src/requestSigner.ts new file mode 100644 index 0000000..4580fe0 --- /dev/null +++ b/src/requestSigner.ts @@ -0,0 +1,106 @@ +import { signEd25519Request } from './utils' + +/** + * Signs the canonical request string for the Ed25519 API key scheme. + * + * Implementations decide where the key material lives — in process, in an HSM, + * or behind a cloud KMS. The returned value is used verbatim as `ACCESS-SIGN`. + */ +export interface RequestSigner { + /** + * @param canonicalString - `method=...&path=...×tamp=...&body=...` + * @returns The base64-encoded raw Ed25519 signature. + */ + sign(canonicalString: string): Promise +} + +/** + * Signs with a PEM PKCS8 Ed25519 private key held in the current process. + */ +export class LocalPrivateKeySigner implements RequestSigner { + constructor(private readonly privateKeyPem: string) { + if (!privateKeyPem || privateKeyPem.trim() === '') { + throw new Error('privateKeyPem is required') + } + } + + async sign(canonicalString: string): Promise { + return signEd25519Request(this.privateKeyPem, canonicalString) + } +} + +/** + * Minimal structural type for the AWS KMS client, so this module does not + * depend on `@aws-sdk/client-kms` at build time. + */ +export interface AwsKmsClientLike { + send(command: any): Promise<{ Signature?: Uint8Array }> +} + +export interface AwsKmsSignerOptions { + /** KMS key id, ARN, or alias (e.g. `alias/signer`). */ + keyId: string + /** Pre-configured KMS client. Provide this or `clientConfig`. */ + client?: AwsKmsClientLike + /** + * Config passed to `new KMSClient(...)` when `client` is omitted. Requires + * `@aws-sdk/client-kms` to be installed; it is loaded lazily. + */ + clientConfig?: Record +} + +/** + * Signs through AWS KMS using an `ECC_NIST_EDWARDS25519` / `SIGN_VERIFY` key. + * The private key never leaves KMS. + */ +export class AwsKmsSigner implements RequestSigner { + private readonly keyId: string + private readonly clientConfig?: Record + private client?: AwsKmsClientLike + private signCommand?: new (input: Record) => any + + constructor(options: AwsKmsSignerOptions) { + if (!options.keyId || options.keyId.trim() === '') { + throw new Error('keyId is required') + } + this.keyId = options.keyId + this.client = options.client + this.clientConfig = options.clientConfig + } + + private async loadSdk(): Promise { + if (this.client && this.signCommand) return + + let kms: any + try { + kms = await import('@aws-sdk/client-kms') + } catch (error) { + throw new Error( + 'AwsKmsSigner requires the "@aws-sdk/client-kms" package. Install it, or pass a preconfigured client via the `client` option.' + ) + } + + this.signCommand = kms.SignCommand + this.client = this.client ?? new kms.KMSClient(this.clientConfig ?? {}) + } + + async sign(canonicalString: string): Promise { + await this.loadSdk() + + const command = new this.signCommand!({ + KeyId: this.keyId, + Message: Buffer.from(canonicalString, 'utf8'), + // ED25519_SHA_512 signs the message itself and requires RAW. The PH + // variant would make KMS prehash on top of ours, changing the signature. + MessageType: 'RAW', + SigningAlgorithm: 'ED25519_SHA_512' + }) + + const response = await this.client!.send(command) + if (!response.Signature) { + throw new Error(`KMS returned no signature for key ${this.keyId}`) + } + + return Buffer.from(response.Signature).toString('base64') + } +} diff --git a/src/types.ts b/src/types.ts index 8e97ad7..e014618 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import { SweepTaskParams } from './api' +import { RequestSigner } from './requestSigner' import { DestinationType, ReserveType, @@ -27,7 +28,13 @@ export class TransactionError extends Error { export interface APICredentials { apiKey: string - apiSecret: string + + // HMAC-SHA256 scheme + apiSecret?: string + + // Ed25519 scheme — signs the canonical request string. + // Use LocalPrivateKeySigner for an in-process PEM key, or AwsKmsSigner for AWS KMS. + signer?: RequestSigner // Optional authToken?: string diff --git a/src/utils.ts b/src/utils.ts index fb72cc7..df2bfe5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,18 +2,35 @@ import CryptoJS from 'crypto-js' import crypto from 'crypto' import { WebhookEvent } from './types' +export function buildCanonicalString(params: Record): string { + return Object.entries(params) + .map(([key, value]) => `${key}=${value}`) + .join('&') +} + export async function computeHMAC( apiSecret: string, params: Record ): Promise { - const encodedParams = Object.entries(params) - .map(([key, value]) => `${key}=${value}`) - .join('&') + const encodedParams = buildCanonicalString(params) const digest = CryptoJS.HmacSHA256(encodedParams, apiSecret) return digest.toString(CryptoJS.enc.Hex) } +/** + * Signs a canonical request string with an Ed25519 private key. + * + * @param privateKeyPem - The Ed25519 private key in PEM PKCS8 format. + * @param canonicalString - The canonical string to sign (method/path/timestamp/body). + * @returns The base64-encoded raw signature (ACCESS-SIGN header value). + */ +export function signEd25519Request(privateKeyPem: string, canonicalString: string): string { + const privateKey = crypto.createPrivateKey(privateKeyPem) + const signature = crypto.sign(null, Buffer.from(canonicalString), privateKey) + return signature.toString('base64') +} + export async function computeHMACForWebhook( apiSecret: string, event: WebhookEvent diff --git a/test/apiKeySigning.spec.ts b/test/apiKeySigning.spec.ts new file mode 100644 index 0000000..6d8e448 --- /dev/null +++ b/test/apiKeySigning.spec.ts @@ -0,0 +1,63 @@ +import crypto from 'crypto' +import { describe, test, expect } from 'vitest' +import { buildCanonicalString, signEd25519Request } from '../src/utils' + +const PRIVATE_KEY_PEM = `-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIKEwTZJdm4dD43IyMtLIVeKTknb+1YCibdlkrGGbiZjA +-----END PRIVATE KEY-----` + +const PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEA1AlJfRq0X5fwCfxzZhXBFSqbPwyqjvq1OE0U5vSDK3g= +-----END PUBLIC KEY-----` + +describe('buildCanonicalString', () => { + test('matches the documented canonical string format', () => { + const canonicalString = buildCanonicalString({ + method: 'POST', + path: '/api/v1/workspaces/abc-123/invite', + timestamp: '1735689600', + body: '{"email":"a@b.com"}' + }) + + expect(canonicalString).toBe( + 'method=POST&path=/api/v1/workspaces/abc-123/invite×tamp=1735689600&body={"email":"a@b.com"}' + ) + }) +}) + +describe('signEd25519Request', () => { + const canonicalString = buildCanonicalString({ + method: 'GET', + path: '/api/v1/web3/wallet-detail', + timestamp: '1706783229', + body: '' + }) + + test('produces a signature verifiable with the matching public key', () => { + const signature = signEd25519Request(PRIVATE_KEY_PEM, canonicalString) + const publicKey = crypto.createPublicKey(PUBLIC_KEY_PEM) + + const isValid = crypto.verify( + null, + Buffer.from(canonicalString), + publicKey, + Buffer.from(signature, 'base64') + ) + + expect(isValid).toBe(true) + }) + + test('fails verification if the canonical string is tampered with', () => { + const signature = signEd25519Request(PRIVATE_KEY_PEM, canonicalString) + const publicKey = crypto.createPublicKey(PUBLIC_KEY_PEM) + + const isValid = crypto.verify( + null, + Buffer.from(canonicalString + 'x'), + publicKey, + Buffer.from(signature, 'base64') + ) + + expect(isValid).toBe(false) + }) +}) diff --git a/test/requestSigner.spec.ts b/test/requestSigner.spec.ts new file mode 100644 index 0000000..3d218dd --- /dev/null +++ b/test/requestSigner.spec.ts @@ -0,0 +1,64 @@ +import crypto from 'crypto' +import fs from 'fs' +import path from 'path' +import { describe, test, expect } from 'vitest' +import { LocalPrivateKeySigner, AwsKmsSigner } from '../src' +import { buildCanonicalString } from '../src/utils' + +const canonicalString = buildCanonicalString({ + method: 'POST', + path: '/api/v1/wallets/a16fdca6-5b7b-4668-83f7-826d4db44594/withdrawals', + timestamp: '1735689600', + body: '{"amount":"10000"}' +}) + +const verify = (signature: string, publicKeyPem: string) => + crypto.verify( + null, + Buffer.from(canonicalString), + crypto.createPublicKey(publicKeyPem), + Buffer.from(signature, 'base64') + ) + +describe('LocalPrivateKeySigner', () => { + test('produces a signature verifiable with the derived public key', async () => { + const privateKeyPem = fs.readFileSync(path.resolve(process.cwd(), 'pk.pem'), 'utf8') + const publicKeyPem = crypto + .createPublicKey(privateKeyPem) + .export({ type: 'spki', format: 'pem' }) + .toString() + + const signature = await new LocalPrivateKeySigner(privateKeyPem).sign(canonicalString) + + expect(verify(signature, publicKeyPem)).toBe(true) + }) +}) + +describe('AwsKmsSigner', () => { + // Requires localstack on :4566 with an ECC_NIST_EDWARDS25519 SIGN_VERIFY key + // aliased as alias/signer, and its public key exported to pub.pem. + const signer = new AwsKmsSigner({ + keyId: 'alias/signer', + clientConfig: { + region: 'ap-southeast-1', + endpoint: 'http://localhost:4566', + credentials: { accessKeyId: 'test', secretAccessKey: 'test' } + } + }) + + test('produces a signature verifiable with the KMS public key', async () => { + const publicKeyPem = fs.readFileSync(path.resolve(process.cwd(), 'pub.pem'), 'utf8') + + const signature = await signer.sign(canonicalString) + + expect(verify(signature, publicKeyPem)).toBe(true) + }) + + test('fails verification if the canonical string is tampered with', async () => { + const publicKeyPem = fs.readFileSync(path.resolve(process.cwd(), 'pub.pem'), 'utf8') + + const signature = await signer.sign(canonicalString + 'x') + + expect(verify(signature, publicKeyPem)).toBe(false) + }) +})