@effected/crypto

中文文档
0.7.0发布于 2026/11/10MITTypeScriptES Module零依赖构建来源已验证近 30 天下载 6324

HMAC, message digests, constant-time comparison and AWS SigV4 request signing on Effect v4's core Mac and Digest services, safe for Workers.

原始使用文档
文档目录13 节
  1. Why @effected/crypto
  2. Install
  3. What to provide
  4. Sign and verify an HMAC
  5. Hash a message
  6. Compare in constant time
  7. Sign an S3 request
  8. Detect drift
  9. Keep an HTTP error without its credentials
  10. Errors
  11. Testing
  12. What it does not do
  13. License

@effected/crypto

npm License: MIT Node.js %3E%3D24.11.0

HMAC sign and verify, message digests, constant-time comparison and AWS Signature Version 4 request signing for Effect v4, built on core effect/crypto's Mac and Digest services. It has no node: import, so the same code runs on Node, Bun, Deno, browsers and Cloudflare Workers. It also ships Fingerprint, stable SHA-256 fingerprints of JSON-shaped values with a three-way drift decision, and HttpRedaction, which strips credentials from HTTP errors before you keep them.

Pre-1.0.0. This package is part of the @effected/* kit, built on stable Effect v4 and still in 0.x development. To keep your effect and @effect/* versions on the line the kit is built and tested against, install @effected/pnpm-plugin-effected.

Stability: unstable. This package's API surface is not yet considered complete and may change across 0.x releases. Pin an exact version. Full policy: release strategy.

Why @effected/crypto

Core effect/crypto already computes and checks MACs (Mac), hashes bytes (Digest) and compares bytes in constant time (Mac.timingSafeEqual). Use it directly when you already hold a Mac key and the message as one Uint8Array, and want raw tag bytes back.

Reach for this package when you need what core leaves to you:

  • The shapes webhooks and APIs use. Hmac takes a Redacted string or raw bytes as the key, and a string, bytes or a list of parts as the message, so ${timestamp}. plus a raw body is signed without building the joined buffer yourself. It returns, or verifies, hex, base64 or base64url as well as bytes. A received signature that does not decode answers false instead of failing.
  • Hashing a prefix plus a body. Hash.digest takes the same messages and returns the same encodings as Hmac.sign, over core's Digest, so sha256(window + "." + body) is one call and hashes exactly what node:crypto's createHash().update(a).update(b) hashes.
  • Comparing a shared secret. ConstantTime.secretsEqual MACs both sides under a fresh random key before comparing them, so neither the secret's length nor a matching prefix leaks.
  • AWS SigV4. SigV4.sign signs a request for S3 or any S3-compatible store (R2, MinIO, Spaces), and returns the exact encoded path and query it signed.
  • Drift detection. Fingerprint.of hashes a canonical JSON form of a value, and Fingerprint.decide turns three fingerprints into Apply, Unchanged or Drift.
  • Keeping HTTP errors safely. Core's HttpClientError keeps the live request, authorization header included, as a readable field. HttpRedaction.cause returns a copy with the credentials replaced.

Every failure is core's CryptoError or one of this package's three tagged input errors; nothing in it reads the clock.

Install

npm install @effected/crypto effect
pnpm add @effected/crypto effect

On Node, requires Node.js >=24.11.0; it also runs on any other WebCrypto runtime, Cloudflare Workers included. effect v4 is the only peer, and there are no runtime dependencies. All @effected/* packages are ESM-only.

What to provide

The keyed and hashing operations require core services in R, which you provide once at your program's edge:

OperationRequires
Hmac.sign, Hmac.verify, ConstantTime.secretsEqual, SigV4.signingKeyMac.Mac
SigV4.signMac.Mac and Digest.Digest
Hash.digest, SigV4.canonicalize, Fingerprint.ofDigest.Digest

ConstantTime.equal, SigV4.encodePath, Fingerprint.decide, Fingerprint.canonicalizeResult and every HttpRedaction method are pure and need nothing.

ServiceAny WebCrypto runtime (Workers included)Node
Mac.MacMac.layerWebCrypto.pipe(Layer.provide(SecureRandom.layerWebCrypto)) from effect/cryptoNodeCrypto.layerMac from @effect/platform-node
Digest.DigestDigest.layerWebCrypto from effect/cryptoNodeCrypto.layerDigest from @effect/platform-node
import { Layer } from "effect";
import { Digest, Mac, SecureRandom } from "effect/crypto";

const CryptoLive = Layer.mergeAll(
  Digest.layerWebCrypto,
  Mac.layerWebCrypto.pipe(Layer.provide(SecureRandom.layerWebCrypto)),
);

On Node, Layer.mergeAll(NodeCrypto.layerMac, NodeCrypto.layerDigest) runs on node:crypto instead. NodeServices.layer provides neither service. On the WebCrypto layers, every HMAC awaits a real promise, so a test that forks one and then adjusts TestClock can hang: await it, or gate the clock on a latch.

Sign and verify an HMAC

import { Hmac } from "@effected/crypto";
import type { Redacted } from "effect";

const signDelivery = (secret: Redacted.Redacted<string>, timestamp: string, body: Uint8Array) =>
  Hmac.sign({ algorithm: "SHA-256", key: secret, message: [`${timestamp}.`, body], encoding: "hex" });
// Effect<string, CryptoError, Mac>

const verifyDelivery = (secret: Redacted.Redacted<string>, timestamp: string, body: Uint8Array, signature: string) =>
  Hmac.verify({ algorithm: "SHA-256", key: secret, message: [`${timestamp}.`, body], signature, encoding: "hex" });
// Effect<boolean, CryptoError, Mac>

algorithm is SHA-256 or SHA-512. encoding is hex (lower case on output, either case on input), base64 (padded), base64url (unpadded) or, when omitted on sign, raw bytes. A Redacted key is used as its UTF-8 bytes and read only where it is imported. verify decodes the received signature and checks it with core's constant-time Mac.verify, so any spelling that decodes to the right MAC verifies; one that does not decode, or has the wrong length, answers false. An empty key fails with CryptoError.

Hash a message

import { Hash } from "@effected/crypto";

const archiveKey = (window: number, body: Uint8Array) =>
  Hash.digest({ algorithm: "SHA-256", message: [`${window}.`, body], encoding: "hex" });
// Effect<string, never, Digest>

algorithm is MD5, SHA-1, SHA-256, SHA-384 or SHA-512, every algorithm core's Digest supports; MD5 and SHA-1 are broken for collision resistance, so use them only to match an existing protocol or checksum. The message and the encodings are those of Hmac.sign: a string is its UTF-8 bytes (a lone surrogate becomes EF BF BD, exactly as node:crypto writes it), a list is its parts' bytes joined in order, and encoding is hex, base64, base64url or, when omitted, raw bytes. Each part is encoded on its own, so a surrogate pair split across two parts is two lone surrogates, as it is across two update calls. A digest cannot fail. On Digest.layerWebCrypto a runtime with no crypto.subtle is a defect: a SHA digest dies, while MD5 runs in pure TypeScript and keeps working.

Compare in constant time

import { ConstantTime } from "@effected/crypto";
import type { Redacted } from "effect";

// Two digests or MACs, whose length is public:
const sameDigest = ConstantTime.equal(expectedHex, receivedHex);
// boolean

// A shared secret, whose length is not:
const checkApiKey = (expected: Redacted.Redacted<string>, received: string) =>
  ConstantTime.secretsEqual(expected, received);
// Effect<boolean, CryptoError, Mac>

equal takes two byte arrays or two strings (compared as UTF-8) and returns false at once on a length mismatch, which is harmless for digests. secretsEqual generates a fresh HMAC-SHA256 key on every call, MACs the expected side and verifies the received side against it, so only two 32-byte tags are ever compared. Both are best-effort: a JavaScript engine is free to optimise a loop, so no comparison in JavaScript is guaranteed constant-time.

Sign an S3 request

import type { SigV4Credentials } from "@effected/crypto";
import { SigV4 } from "@effected/crypto";
import { Effect } from "effect";

const readFirstBytes = (credentials: SigV4Credentials, host: string, key: string, now: Date) =>
  SigV4.sign(
    {
      method: "GET",
      host,
      path: key,
      headers: { range: "bytes=0-9" },
      payloadHash: SigV4.EMPTY_PAYLOAD_HASH,
      now,
    },
    credentials,
  ).pipe(
    Effect.map((signed) => ({
      url: `https://${host}${signed.path}${signed.query === "" ? "" : `?${signed.query}`}`,
      headers: signed.headers,
    })),
  );
// Effect<{ url: string; headers: Record<string, string> }, SigV4PathError | SigV4EncodingError | CryptoError, Digest | Mac>

SigV4Credentials holds accessKeyId, a Redacted secretAccessKey, an optional Redacted sessionToken (sent as x-amz-security-token), region (auto for R2) and service. The request carries its own signing instant in now, as a Date or a DateTime, so a signature is reproducible. Pass path and query unencoded: sign encodes the path one segment at a time (escaping !'()*, which encodeURIComponent leaves alone), sorts and encodes the query, and returns the strings it signed as path and query. Send exactly those; re-encoding them breaks the signature.

The payload hash is the SHA-256 of body unless you pass payloadHash: SigV4.EMPTY_PAYLOAD_HASH for an empty body, SigV4.UNSIGNED_PAYLOAD for a streamed upload, or a hash you computed. The returned headers are yours, names lower-cased, plus host, x-amz-date, x-amz-content-sha256, the session token when there is one, and authorization.

A path with a segment that is exactly . or .. fails with SigV4PathError before anything is signed: every URL parser collapses such a segment, so the request would reach a different path from the one signed. A method, host, path, query key or value, or header name or value holding a lone UTF-16 surrogate fails with SigV4EncodingError, naming the component, rather than dying inside encodeURIComponent. SigV4.canonicalize and SigV4.signingKey are exported for pinning tests against AWS's documented examples.

Detect drift

import { Fingerprint } from "@effected/crypto";
import { Effect } from "effect";

const plan = (applied: string | undefined, live: unknown, desired: unknown) =>
  Effect.gen(function* () {
    const decision = Fingerprint.decide({
      applied,
      live: yield* Fingerprint.of(live),
      desired: yield* Fingerprint.of(desired),
    });
    switch (decision._tag) {
      case "Apply":
        return "apply";
      case "Unchanged":
        return "skip";
      case "Drift":
        return `changed outside this tool: ${decision.applied} is now ${decision.live}`;
    }
  });
// Effect<string, FingerprintError, Digest>

A fingerprint is the lower-case SHA-256 hex of a canonical JSON text: object keys sorted at every depth, array order kept, and undefined members dropped, so an absent key and an undefined one fingerprint alike. Otherwise it follows JSON.stringify, with a Date written as its ISO string. A value with no faithful JSON form is refused with FingerprintError, never coerced: a bigint, function, symbol, non-finite number, cycle, or any object that is not a Date, an array or a plain object. That includes a Map, a class instance, and Effect data types such as Option, whatever toJSON they carry; encode them to plain JSON first, for example with their Schema. Fingerprint.canonicalizeResult returns the canonical text as a Result, for explaining a drift report.

decide applies its rules in order. With no applied baseline it answers Apply. A live that differs from applied is Drift, carrying both, even when desired changed too, because that is when you most want to be told before anything is overwritten. Otherwise, a desired that differs from applied is Apply, and anything else is Unchanged. After an Apply, persist the desired fingerprint as the next run's applied. The three cases are DriftApply, DriftUnchanged and DriftDetected, and DriftDecision is their schema union, so a decision can be logged or stored and decoded again.

Keep an HTTP error without its credentials

import { HttpRedaction } from "@effected/crypto";
import { Effect, Redacted, Schema } from "effect";
import { HttpClient } from "effect/http";

class RegistryError extends Schema.TaggedError<RegistryError>()("RegistryError", {
  cause: Schema.Defect(),
}) {}

const fetchManifest = (token: Redacted.Redacted<string>, url: string) =>
  HttpClient.get(url, { headers: { authorization: `Bearer ${Redacted.value(token)}` } }).pipe(
    Effect.mapError((cause) => new RegistryError({ cause: HttpRedaction.cause(cause) })),
  );

HttpRedaction.url("https://user:[email protected]/pkg?token=abc&page=2");
// "https://REDACTED:[email protected]/pkg?token=REDACTED&page=2"

Core redacts credentials only when it renders an error (toJSON, inspect). A logger, serializer or anything else that walks the object still reads them, so scrub at the point you keep the cause. HttpRedaction.cause copies an HttpClientError, a bare error reason, a request or a response, and passes anything else through unchanged.

The copy keeps the method, the status and the URL, with userinfo and sensitive query values replaced by REDACTED: core's own list, the SigV4 presigned set, Azure SAS sig, token, *_token, api_key, client_secret, secret, password and code. Sensitive header values (authorization, proxy-authorization, cookie, x-api-key, private-token, and the x-*-token, x-*-api-key and x-*-signature family) read REDACTED, set-cookie and cookie are dropped from response headers, and bodies are dropped. A nested foreign cause, such as the TypeError a failed fetch rejects with, is rebuilt on the same prototype with its message and stack scrubbed. HttpRedaction.text scrubs URLs and Bearer, Basic and token credentials out of free text in linear time, and HttpRedaction.error, request, response and isSensitiveHeader are the individual pieces.

Errors

ErrorRaised byMeaning
CryptoError (core, effect/crypto), reason InvalidKeyHmac.sign, Hmac.verifyThe key is empty.
CryptoError (core), any reasonHmac, ConstantTime.secretsEqual, SigV4.sign, SigV4.signingKeyThe Mac layer refused or mishandled a key it should accept. A well-behaved layer does not reach this from secretsEqual or SigV4, whose keys are never empty.
SigV4PathErrorSigV4.sign, SigV4.canonicalizeThe path has a segment that is exactly . or ... Carries path and segment.
SigV4EncodingErrorSigV4.sign, SigV4.canonicalizeA method, host, path, query key or value, or header name or value holds a lone UTF-16 surrogate; a string body is not checked. Carries component (method, host, path, query-key, query-value, header-name, header-value) and, when there is one, the subject (query key or header name).
FingerprintErrorFingerprint.of, Fingerprint.canonicalizeResultThe value has no canonical JSON form. Carries path (a JSON-pointer-like location, "" for the root) and detail (what was found there).

CryptoError means the wiring is wrong, so turn it into a defect with Effect.orDie. The tagged errors are caller input: recover with Effect.catchTag("SigV4PathError", ...), Effect.catchTag("SigV4EncodingError", ...) or Effect.catchTag("FingerprintError", ...). This package does not re-export CryptoError; import it from effect/crypto. On the WebCrypto layers, a runtime with no crypto.subtle is not a failure at all: the effect dies. Hmac.verify reports a malformed signature as false, never as an error.

Testing

The package ships no test double, because none is needed: provide the WebCrypto layers from What to provide in tests, exactly as a Worker would. To check your own signing against an independent implementation, compare with node:crypto's createHmac in the test, or provide NodeCrypto.layerMac and NodeCrypto.layerDigest and run the same test on both families. Every operation takes its time from the caller, so no test needs TestClock to sign or fingerprint.

What it does not do

  • Hash a stream. Hash and Digest hash bytes already in memory, and no WebCrypto primitive hashes a stream, so hashing a file without holding it in memory needs node:crypto.
  • Seal a box. The libsodium sealed box that GitHub's secrets API needs lives in @effected/github, because its engines would add runtime dependencies here.
  • Fingerprint by RFC 8785. Fingerprint drops undefined members, which RFC 8785 refuses; for RFC 8785 canonical JSON, use JsoncFingerprint from @effected/jsonc.
  • Verify webhooks. The vendor schemes (GitHub, Stripe, Standard Webhooks, Sanity) live in @effected/webhooks, which is built on this package.

License

MIT

版本与发布记录

@effected/crypto 共发布 10 个版本,首次发布于 2026/10/23;过去 12 个月发布了 10 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.7.0latest2026/11/102 天前
0.6.02026/11/094 天前
0.5.02026/11/0310 天前
0.4.02026/11/0112 天前
0.3.02026/10/2915 天前
0.2.12026/10/2618 天前

关于 @effected/crypto 的常见问题

@effected/crypto 是什么?

HMAC, message digests, constant-time comparison and AWS SigV4 request signing on Effect v4's core Mac and Digest services, safe for Workers.

如何安装 @effected/crypto?

在项目目录运行 npm install @effected/crypto,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:effect(~4.1.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@effected/crypto 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@effected/crypto 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@effected/crypto 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=24.11.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@effected/crypto 可以商用吗?

@effected/crypto 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@effected/crypto 的下载量有多少?

根据 npm 官方下载统计,@effected/crypto 在 2026/10/13 – 2026/11/11 这 30 天内被下载 6,324 次,日均约 211 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@effected/crypto 还在维护吗?

最新版本 0.7.0 发布于 2026/11/10(2 天前);至今共发布 10 个版本,其中过去 12 个月发布了 10 个;首次发布于 2026/10/23。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。