@bradford-tech/supabase-integrity-attest

中文文档
0.9.1发布于 2026/08/01MITES Module构建来源已验证近 30 天下载 3839

Verify Apple App Attest attestations and assertions using WebCrypto.

原始使用文档
文档目录20 节
  1. Install
  2. Quick start
  3. Why this library
  4. Middleware
  5. withAttestation -- device registration
  6. withAssertion -- protected requests
  7. Sign count atomicity
  8. Shared options
  9. Supabase adapter
  10. Low-level API
  11. Assertion
  12. Subpath imports
  13. Error handling
  14. Attestation error codes
  15. Assertion error codes
  16. Development environment
  17. Documentation
  18. Contributing
  19. Security
  20. License

supabase-integrity-attest

Apple App Attest server-side verification for edge runtimes, using only WebCrypto.

Install

# Deno
deno add jsr:@bradford-tech/supabase-integrity-attest

# npm
npm install @bradford-tech/supabase-integrity-attest

Quick start

Verify a device attestation and extract its public key:

import { verifyAttestation } from "@bradford-tech/supabase-integrity-attest";

const clientDataHash = new Uint8Array(
  await crypto.subtle.digest("SHA-256", new TextEncoder().encode(challenge)),
);

const { publicKeyPem, signCount } = await verifyAttestation(
  { appId: "TEAMID.com.example.app" },
  keyId,          // base64 key identifier from client
  clientDataHash, // SHA-256 of the challenge you issued
  attestation,    // base64 CBOR attestation from client
);
// publicKeyPem: "-----BEGIN PUBLIC KEY-----\nMFkw..."
// signCount: 0

Store publicKeyPem and signCount for this device. Use them to verify future assertions.

Why this library

Existing App Attest verification libraries depend on node:crypto or packages that crash in edge runtimes. appattest-checker-node uses X509Certificate.verify(), which throws ERR_NOT_IMPLEMENTED in Deno. pkijs crashes at module load in Supabase Edge Functions because self.crypto.name is undefined. @peculiar/x509 pulls in tsyringe and reflect-metadata, which rely on global side effects during module initialization.

This library uses only crypto.subtle for cryptographic operations, with asn1js for X.509 parsing and @noble/curves for one operation Deno's WebCrypto doesn't support (P-384 signature verification on Apple's intermediate certificate).

Middleware

Both middleware wrappers below use a Supabase service-role client for database access:

import { createClient } from "jsr:@supabase/supabase-js@2";

const supabase = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);

withAttestation -- device registration

withAttestation wraps a one-time device registration endpoint with automatic challenge consumption, attestation verification, and device key storage:

import { withAttestation } from "@bradford-tech/supabase-integrity-attest";

Deno.serve(withAttestation({
  appId: Deno.env.get("APP_ATTEST_APP_ID")!,
  consumeChallenge: async (challenge) => {
    const id = new TextDecoder().decode(challenge);
    const { data } = await supabase
      .from("attestation_challenges")
      .delete()
      .eq("id", id)
      .select("id");
    return (data?.length ?? 0) > 0;
  },
  storeDeviceKey: async ({ deviceId, publicKeyPem, signCount }) => {
    await supabase
      .from("device_attestations")
      .upsert({ key_id: deviceId, public_key_pem: publicKeyPem, sign_count: signCount });
  },
}, (_req, ctx) => {
  // ctx.deviceId, ctx.publicKeyPem, ctx.signCount, ctx.receipt, ctx.timings
  return Response.json({ deviceId: ctx.deviceId });
}));

consumeChallenge must be atomic: return true if the challenge was valid, unused, and unexpired (and is now consumed), false otherwise. Use DELETE ... RETURNING to guarantee single-use semantics.

The default extractor reads a JSON body:

POST /functions/v1/attest
Content-Type: application/json

{"keyId": "<base64>", "challenge": "<base64>", "attestation": "<base64>"}

withAssertion -- protected requests

withAssertion wraps any protected endpoint with automatic assertion verification, device key lookup, and sign count commit:

import { withAssertion } from "@bradford-tech/supabase-integrity-attest";

Deno.serve(withAssertion({
  appId: Deno.env.get("APP_ATTEST_APP_ID")!,
  getDeviceKey: async (deviceId) => {
    const { data } = await supabase
      .from("device_attestations")
      .select("public_key_pem, sign_count")
      .eq("key_id", deviceId)
      .single();
    return data
      ? { publicKeyPem: data.public_key_pem, signCount: data.sign_count }
      : null;
  },
  commitSignCount: async (deviceId, newSignCount) => {
    const { data } = await supabase
      .from("device_attestations")
      .update({ sign_count: newSignCount })
      .eq("key_id", deviceId)
      .lt("sign_count", newSignCount)
      .select("key_id");
    return (data?.length ?? 0) > 0;
  },
}, async (_req, { rawBody }) => {
  const payload = JSON.parse(new TextDecoder().decode(rawBody));
  return Response.json({ ok: true });
}));

The client sends the assertion and device ID in headers. The request body is the signed client data:

POST /functions/v1/your-endpoint
X-App-Attest-Assertion: <base64-encoded assertion>
X-App-Attest-Device-Id: <base64-encoded keyId>
Content-Type: application/json

{"text": "Hello world", "voice": "en-US"}

Sign count atomicity

commitSignCount must use compare-and-swap: only update the stored count if the current value is strictly less than newSignCount. An unconditional UPDATE ... SET sign_count = $1 silently breaks replay protection when two requests arrive concurrently.

UPDATE device_attestations
   SET sign_count = $1, last_seen_at = now()
 WHERE key_id = $2 AND sign_count < $1

Return true if the row was updated. The library converts false into AssertionError(SIGN_COUNT_STALE).

Shared options

Once you have multiple protected functions, extract the shared options:

// supabase/functions/_shared/attest.ts
import type { WithAssertionOptions } from "@bradford-tech/supabase-integrity-attest";

export const assertionOptions: WithAssertionOptions = {
  appId: Deno.env.get("APP_ATTEST_APP_ID")!,
  // ... getDeviceKey, commitSignCount as above
};
// supabase/functions/text-to-speech/index.ts
import { withAssertion } from "@bradford-tech/supabase-integrity-attest";
import { assertionOptions } from "../_shared/attest.ts";

Deno.serve(withAssertion(assertionOptions, async (_req, { rawBody }) => {
  const { text, voice } = JSON.parse(new TextDecoder().decode(rawBody));
  return Response.json({ audio: "..." });
}));

Supabase adapter

On Supabase, skip writing the callbacks entirely. The /supabase subpath ships createSupabaseAdapter(), which provides all four storage callbacks plus issueChallenge(), backed by two Postgres tables:

import { withAttestation } from "@bradford-tech/supabase-integrity-attest/attestation";
import { createSupabaseAdapter } from "@bradford-tech/supabase-integrity-attest/supabase";

const adapter = createSupabaseAdapter(supabase); // service-role client

// Challenge endpoint
const { challengeBase64, expiresAt } = await adapter.issueChallenge("attestation");

// Registration endpoint -- adapter supplies consumeChallenge + storeDeviceKey
Deno.serve(withAttestation(
  { appId: Deno.env.get("APP_ATTEST_APP_ID")!, ...adapter },
  (_req, ctx) => Response.json({ deviceId: ctx.deviceId }),
));

// Protected endpoints -- adapter supplies getDeviceKey + commitSignCount
// withAssertion({ appId, ...adapter }, handler)

The schema ships with the package as sql/app_attest.sql (both tables, RLS locked to service role, expiry index, pg_cron sweep of expired challenges). Table names and challenge TTL are configurable via the options argument. @supabase/supabase-js is not a dependency — the client is typed structurally, so any recent client works.

App Attest proves device integrity, not user identity: the adapter and middleware compose alongside Supabase Auth, AWS Cognito, or any bearer JWT.

Low-level API

For full control over the verification flow, use verifyAttestation and verifyAssertion directly.

The quick start above shows verifyAttestation. Note that clientDataHash must be SHA-256 of the challenge, not the raw challenge. Client SDKs (Expo's attestKeyAsync, native DCAppAttestService.attestKey) hash the challenge internally before passing to Apple; you must produce the same hash server-side. The withAttestation middleware handles this automatically.

Assertion

import { verifyAssertion } from "@bradford-tech/supabase-integrity-attest";

const { signCount } = await verifyAssertion(
  { appId: "TEAMID.com.example.app" },
  assertion,         // base64 CBOR from client
  clientData,        // the request payload that was signed
  storedPublicKeyPem,
  storedSignCount,
);
// Update stored signCount to signCount

Subpath imports

Import only what you need to reduce bundle size:

// Full library (attestation + assertion)
import { verifyAttestation, verifyAssertion } from "@bradford-tech/supabase-integrity-attest";

// Assertion only -- skips asn1js and @noble/curves
import { verifyAssertion, withAssertion } from "@bradford-tech/supabase-integrity-attest/assertion";

// Attestation only
import { verifyAttestation, withAttestation } from "@bradford-tech/supabase-integrity-attest/attestation";

// Supabase storage adapter
import { createSupabaseAdapter } from "@bradford-tech/supabase-integrity-attest/supabase";

Error handling

import {
  AttestationError,
  AssertionError,
} from "@bradford-tech/supabase-integrity-attest";

try {
  await verifyAttestation(appInfo, keyId, clientDataHash, attestation);
} catch (e) {
  if (e instanceof AttestationError) {
    console.log(e.code);
    // => "NONCE_MISMATCH"
  }
}

Attestation error codes

CodeMeaning
INVALID_FORMATCBOR decoding or structural validation failed
INVALID_CERTIFICATE_CHAINX.509 certificate chain verification failed
NONCE_MISMATCHComputed nonce does not match the certificate nonce
RP_ID_MISMATCHRP ID hash does not match SHA-256 of the app ID
KEY_ID_MISMATCHPublic key hash does not match the provided key ID
INVALID_COUNTERSign count is not zero (required for attestation)
INVALID_AAGUIDAAGUID does not match the expected environment
CHALLENGE_INVALIDChallenge missing, expired, or already consumed (withAttestation only)
INTERNAL_ERRORStorage callback or internal error (withAttestation only)

Assertion error codes

CodeMeaning
INVALID_FORMATCBOR decoding or structural validation failed
RP_ID_MISMATCHRP ID hash does not match SHA-256 of the app ID
COUNTER_NOT_INCREMENTEDSign count was not greater than the stored value
SIGNATURE_INVALIDECDSA signature verification failed
DEVICE_NOT_FOUNDNo device key for the given device ID (withAssertion only)
INTERNAL_ERRORStorage callback or internal error (withAssertion only)
SIGN_COUNT_STALEConcurrent request already advanced the counter (withAssertion only)

Development environment

For apps using Apple's development App Attest environment, pass developmentEnv: true:

await verifyAttestation(
  { appId: "TEAMID.com.example.app", developmentEnv: true },
  keyId,
  clientDataHash,
  attestation,
);

withAttestation also accepts developmentEnv in its options.

Documentation

Full documentation at integrity-attest.bradford.tech.

Contributing

Issues and pull requests are welcome on GitHub.

Security

See SECURITY.md for vulnerability reporting.

License

MIT

版本与发布记录

@bradford-tech/supabase-integrity-attest 共发布 15 个版本,首次发布于 2026/03/08;过去 12 个月发布了 15 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.9.1latest2026/08/013 个月前
0.9.02026/08/013 个月前
0.8.22026/04/256 个月前
0.8.12026/04/246 个月前
0.8.02026/04/216 个月前
0.7.02026/04/136 个月前

关于 @bradford-tech/supabase-integrity-attest 的常见问题

@bradford-tech/supabase-integrity-attest 是什么?

Verify Apple App Attest attestations and assertions using WebCrypto.

如何安装 @bradford-tech/supabase-integrity-attest?

在项目目录运行 npm install @bradford-tech/supabase-integrity-attest,或使用 pnpm add / yarn add / bun add。

@bradford-tech/supabase-integrity-attest 支持 TypeScript 吗?

该包的 package.json 没有声明类型入口。需要类型提示时,可以在 npm 上查找社区维护的 @types/bradford-tech__supabase-integrity-attest 是否存在。

@bradford-tech/supabase-integrity-attest 支持 ESM 还是 CommonJS?

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

@bradford-tech/supabase-integrity-attest 可以商用吗?

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

@bradford-tech/supabase-integrity-attest 的下载量有多少?

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

@bradford-tech/supabase-integrity-attest 还在维护吗?

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