@zerodev/wallet-core 0.0.1-alpha.17 → 0.0.1-alpha.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/_cjs/actions/auth/registerWithOTP.js.map +1 -1
  2. package/dist/_cjs/client/authProxy.js +1 -1
  3. package/dist/_cjs/client/authProxy.js.map +1 -1
  4. package/dist/_cjs/constants.js +2 -1
  5. package/dist/_cjs/constants.js.map +1 -1
  6. package/dist/_cjs/core/createZeroDevWallet.js +9 -3
  7. package/dist/_cjs/core/createZeroDevWallet.js.map +1 -1
  8. package/dist/_cjs/utils/encryptOtpAttempt.js +57 -0
  9. package/dist/_cjs/utils/encryptOtpAttempt.js.map +1 -0
  10. package/dist/_cjs/utils/hpke.js +89 -0
  11. package/dist/_cjs/utils/hpke.js.map +1 -0
  12. package/dist/_esm/actions/auth/registerWithOTP.js.map +1 -1
  13. package/dist/_esm/client/authProxy.js +9 -4
  14. package/dist/_esm/client/authProxy.js.map +1 -1
  15. package/dist/_esm/constants.js +6 -0
  16. package/dist/_esm/constants.js.map +1 -1
  17. package/dist/_esm/core/createZeroDevWallet.js +12 -4
  18. package/dist/_esm/core/createZeroDevWallet.js.map +1 -1
  19. package/dist/_esm/utils/encryptOtpAttempt.js +81 -0
  20. package/dist/_esm/utils/encryptOtpAttempt.js.map +1 -0
  21. package/dist/_esm/utils/hpke.js +135 -0
  22. package/dist/_esm/utils/hpke.js.map +1 -0
  23. package/dist/_types/actions/auth/registerWithOTP.d.ts +6 -0
  24. package/dist/_types/actions/auth/registerWithOTP.d.ts.map +1 -1
  25. package/dist/_types/client/authProxy.d.ts +13 -7
  26. package/dist/_types/client/authProxy.d.ts.map +1 -1
  27. package/dist/_types/constants.d.ts +1 -0
  28. package/dist/_types/constants.d.ts.map +1 -1
  29. package/dist/_types/core/createZeroDevWallet.d.ts +10 -0
  30. package/dist/_types/core/createZeroDevWallet.d.ts.map +1 -1
  31. package/dist/_types/utils/encryptOtpAttempt.d.ts +40 -0
  32. package/dist/_types/utils/encryptOtpAttempt.d.ts.map +1 -0
  33. package/dist/_types/utils/hpke.d.ts +38 -0
  34. package/dist/_types/utils/hpke.d.ts.map +1 -0
  35. package/dist/tsconfig.build.tsbuildinfo +1 -1
  36. package/package.json +4 -1
  37. package/src/actions/auth/registerWithOTP.ts +6 -0
  38. package/src/client/authProxy.ts +14 -8
  39. package/src/constants.ts +8 -0
  40. package/src/core/createZeroDevWallet.ts +23 -4
  41. package/src/utils/encryptOtpAttempt.ts +142 -0
  42. package/src/utils/hpke.ts +245 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerodev/wallet-core",
3
- "version": "0.0.1-alpha.17",
3
+ "version": "0.0.1-alpha.18",
4
4
  "description": "ZeroDev Wallet SDK built on Turnkey",
5
5
  "main": "./dist/_cjs/index.js",
6
6
  "module": "./dist/_esm/index.js",
@@ -49,6 +49,8 @@
49
49
  ],
50
50
  "license": "MIT",
51
51
  "dependencies": {
52
+ "@noble/curves": "^2.2.0",
53
+ "@noble/hashes": "^2.2.0",
52
54
  "@turnkey/http": "^3.12.1",
53
55
  "@turnkey/iframe-stamper": "^2.5.0",
54
56
  "@turnkey/indexed-db-stamper": "^1.1.1",
@@ -59,6 +61,7 @@
59
61
  "viem": "^2.38.0"
60
62
  },
61
63
  "devDependencies": {
64
+ "@hpke/core": "^1.9.0",
62
65
  "@types/node": "^20.0.0",
63
66
  "typescript": "5.9.3"
64
67
  },
@@ -31,6 +31,12 @@ export type RegisterWithOTPParameters = {
31
31
  export type RegisterWithOTPReturnType = {
32
32
  /** The OTP ID needed for verification */
33
33
  otpId: string
34
+ /**
35
+ * Signed encryption target bundle issued by the TLS Fetcher enclave for
36
+ * this OTP session. Passed verbatim to the verify step so the SDK can
37
+ * HPKE-encrypt the OTP attempt to the enclave's ephemeral target key.
38
+ */
39
+ otpEncryptionTargetBundle: string
34
40
  }
35
41
 
36
42
  /**
@@ -10,10 +10,11 @@ export type AuthProxyClientConfig = {
10
10
  export type AuthProxyVerifyOtpRequest = {
11
11
  /** The OTP ID from registration */
12
12
  otpId: string
13
- /** The OTP code entered by the user */
14
- otpCode: string
15
- /** The public key to associate with the verification */
16
- public_key: string
13
+ /**
14
+ * HPKE-sealed bundle containing `{otp_code, public_key}` encrypted to the
15
+ * enclave's per-session target key. Produced by `encryptOtpAttempt`.
16
+ */
17
+ encryptedOtpBundle: string
17
18
  }
18
19
 
19
20
  export type AuthProxyVerifyOtpResponse = {
@@ -62,15 +63,20 @@ export function createAuthProxyClient(config: AuthProxyClientConfig) {
62
63
 
63
64
  return {
64
65
  /**
65
- * Verifies an OTP code with Turnkey's Auth Proxy
66
+ * Verifies an OTP attempt with Turnkey's Auth Proxy.
66
67
  *
67
- * Returns a verificationToken that should be passed to the backend's
68
- * /auth/login/otp endpoint along with a client signature.
68
+ * The `encryptedOtpBundle` is HPKE-sealed `{otp_code, public_key}` JSON
69
+ * (see `encryptOtpAttempt`). The auth proxy forwards the ciphertext to
70
+ * the TLS Fetcher enclave, which decrypts it, verifies the OTP code, and
71
+ * returns a `verificationToken` bound to the embedded public key.
72
+ *
73
+ * Pass the returned `verificationToken` to `/auth/login/otp` along with
74
+ * a client signature to complete the login.
69
75
  */
70
76
  async verifyOtp(
71
77
  params: AuthProxyVerifyOtpRequest,
72
78
  ): Promise<AuthProxyVerifyOtpResponse> {
73
- return request<AuthProxyVerifyOtpResponse>('/v1/otp_verify', params)
79
+ return request<AuthProxyVerifyOtpResponse>('/v1/otp_verify_v2', params)
74
80
  },
75
81
  }
76
82
  }
package/src/constants.ts CHANGED
@@ -3,3 +3,11 @@ export const DEFAULT_IFRAME_CONTAINER_ID = 'turnkey-auth-iframe-container-id'
3
3
  export const DEFAULT_IFRAME_ELEMENT_ID = 'turnkey-default-iframe-element-id'
4
4
  export const DEFAULT_ORGANIZATION_ID = '0d98e826-dd8f-44ca-a585-3afcd27d4002'
5
5
  export const KMS_SERVER_URL = 'https://kms.staging.zerodev.app'
6
+
7
+ // Pinned ECDSA P-256 public key (uncompressed, 65 bytes hex) of Turnkey's
8
+ // TLS Fetcher Sign enclave. Used to verify the signature on the OTP encryption
9
+ // target bundle returned by /auth/init/otp before HPKE-encrypting the OTP
10
+ // attempt. The bundle's `dataSignature` is verified against this key, so a
11
+ // compromised proxy cannot substitute its own ephemeral key.
12
+ export const TURNKEY_TLS_FETCHER_SIGN_PUBLIC_KEY =
13
+ '046b4f88421f76b6ba418afc2ea1d8ced671337d7db6b80478a60d8531bf8f17fa9a512f0fef96fc0c9b4cd9dff70b34992e520ce04c79d931f6ff6296b547d201'
@@ -25,6 +25,7 @@ import {
25
25
  } from '../storage/manager.js'
26
26
  import { SessionType, type ZeroDevWalletSession } from '../types/session.js'
27
27
  import { buildClientSignature } from '../utils/buildClientSignature.js'
28
+ import { encryptOtpAttempt } from '../utils/encryptOtpAttempt.js'
28
29
  import {
29
30
  base64UrlEncode,
30
31
  generateCompressedPublicKeyFromKeyPair,
@@ -72,6 +73,11 @@ export type AuthParams =
72
73
  mode: 'verifyOtp'
73
74
  otpId: string
74
75
  otpCode: string
76
+ /**
77
+ * The encryption target bundle returned by the matching `sendOtp` call.
78
+ * Required — used to HPKE-encrypt the OTP attempt to the enclave.
79
+ */
80
+ otpEncryptionTargetBundle: string
75
81
  }
76
82
  | {
77
83
  type: 'magicLink'
@@ -85,6 +91,11 @@ export type AuthParams =
85
91
  mode: 'verify'
86
92
  otpId: string
87
93
  code: string
94
+ /**
95
+ * The encryption target bundle returned by the matching `sendMagicLink`
96
+ * (a.k.a. magicLink `send`) call. Required for the encrypted-OTP flow.
97
+ */
98
+ otpEncryptionTargetBundle: string
88
99
  }
89
100
 
90
101
  export interface ZeroDevWalletSDK {
@@ -379,6 +390,7 @@ export async function createZeroDevWallet(
379
390
  mode: 'verifyOtp',
380
391
  otpId: params.otpId,
381
392
  otpCode: params.code,
393
+ otpEncryptionTargetBundle: params.otpEncryptionTargetBundle,
382
394
  }
383
395
  }
384
396
  } else {
@@ -401,7 +413,7 @@ export async function createZeroDevWallet(
401
413
  }
402
414
 
403
415
  if (otpParams.mode === 'verifyOtp') {
404
- const { otpId, otpCode } = otpParams
416
+ const { otpId, otpCode, otpEncryptionTargetBundle } = otpParams
405
417
 
406
418
  // Step 1: Generate new key pair
407
419
  await client.indexedDbStamper.resetKeyPair()
@@ -411,7 +423,15 @@ export async function createZeroDevWallet(
411
423
  throw new Error('Failed to get public key')
412
424
  }
413
425
 
414
- // Step 2: Verify OTP via Auth Proxy
426
+ // Step 2a: HPKE-seal the OTP attempt to the enclave's per-session
427
+ // target key. The auth proxy never sees the plaintext OTP code.
428
+ const encryptedOtpBundle = await encryptOtpAttempt({
429
+ otpCode,
430
+ publicKey: targetPublicKey,
431
+ encryptionTargetBundle: otpEncryptionTargetBundle,
432
+ })
433
+
434
+ // Step 2b: Verify OTP via Auth Proxy
415
435
  if (!cachedAuthProxyConfigId) {
416
436
  const { authProxyConfigId } = await client.getAuthProxyConfigId()
417
437
  cachedAuthProxyConfigId = authProxyConfigId
@@ -422,8 +442,7 @@ export async function createZeroDevWallet(
422
442
 
423
443
  const { verificationToken } = await authProxyClient.verifyOtp({
424
444
  otpId,
425
- otpCode,
426
- public_key: targetPublicKey,
445
+ encryptedOtpBundle,
427
446
  })
428
447
 
429
448
  // Step 3: Build client signature
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Wraps the OTP code + client public key in a Turnkey-compatible HPKE bundle
3
+ * for the `/v1/otp_verify_v2` auth-proxy endpoint.
4
+ *
5
+ * Bundle flow (RFC 9180 mode_base over Turnkey's TLS Fetcher enclave):
6
+ * 1. The backend's /init/otp returns a signed envelope that contains an
7
+ * ephemeral HPKE public key (`targetPublic`) generated fresh by the
8
+ * enclave for this OTP attempt.
9
+ * 2. We verify the envelope's ECDSA signature against a pinned production
10
+ * key (`TURNKEY_TLS_FETCHER_SIGN_PUBLIC_KEY`) so a compromised proxy
11
+ * cannot substitute its own ephemeral key.
12
+ * 3. We HPKE-seal `{otp_code, public_key}` to `targetPublic`. The auth proxy
13
+ * forwards the ciphertext to the enclave; only the enclave can decrypt
14
+ * it. The enclave then issues a `verificationToken` bound to the public
15
+ * key embedded in the plaintext.
16
+ *
17
+ * See: tkhq/go-sdk `examples/email_otp` and `pkg/enclave_encrypt`.
18
+ */
19
+
20
+ import { p256 } from '@noble/curves/nist.js'
21
+ import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'
22
+ import { TURNKEY_TLS_FETCHER_SIGN_PUBLIC_KEY } from '../constants.js'
23
+ import { hpkeSealP256 } from './hpke.js'
24
+
25
+ const BUNDLE_DATA_VERSION = 'v1.0.0'
26
+
27
+ type EncryptionTargetEnvelope = {
28
+ version: string
29
+ /** Hex-encoded JSON: `{ targetPublic, organizationId, userId? }` */
30
+ data: string
31
+ /** Hex-encoded ECDSA-P256 signature over the raw `data` bytes (DER). */
32
+ dataSignature: string
33
+ /** Hex-encoded uncompressed P-256 pubkey of the signing enclave. */
34
+ enclaveQuorumPublic: string
35
+ }
36
+
37
+ type SignedTargetData = {
38
+ targetPublic: string
39
+ organizationId?: string
40
+ userId?: string
41
+ }
42
+
43
+ export type EncryptOtpAttemptParams = {
44
+ /** The OTP code the user entered. */
45
+ otpCode: string
46
+ /**
47
+ * The client's session public key (compressed P-256 hex). The enclave binds
48
+ * this key into the `verificationToken` it issues.
49
+ */
50
+ publicKey: string
51
+ /** The signed envelope returned by `/auth/init/otp`. */
52
+ encryptionTargetBundle: string
53
+ /**
54
+ * Test-only override for the pinned signing key. Production callers should
55
+ * leave this undefined; it exists so tests don't have to use the real key.
56
+ */
57
+ dangerouslyOverrideSignerPublicKey?: string
58
+ }
59
+
60
+ /**
61
+ * Returns a JSON string ready to be sent as `encryptedOtpBundle` on
62
+ * `POST /v1/otp_verify_v2`.
63
+ */
64
+ export async function encryptOtpAttempt({
65
+ otpCode,
66
+ publicKey,
67
+ encryptionTargetBundle,
68
+ dangerouslyOverrideSignerPublicKey,
69
+ }: EncryptOtpAttemptParams): Promise<string> {
70
+ const expectedSignerHex =
71
+ dangerouslyOverrideSignerPublicKey ?? TURNKEY_TLS_FETCHER_SIGN_PUBLIC_KEY
72
+
73
+ let envelope: EncryptionTargetEnvelope
74
+ try {
75
+ envelope = JSON.parse(encryptionTargetBundle)
76
+ } catch (err) {
77
+ throw new Error(
78
+ `encryptOtpAttempt: failed to parse encryption target bundle: ${(err as Error).message}`,
79
+ )
80
+ }
81
+
82
+ if (envelope.version !== BUNDLE_DATA_VERSION) {
83
+ throw new Error(
84
+ `encryptOtpAttempt: unsupported bundle version ${envelope.version}`,
85
+ )
86
+ }
87
+
88
+ if (
89
+ envelope.enclaveQuorumPublic.toLowerCase() !==
90
+ expectedSignerHex.toLowerCase()
91
+ ) {
92
+ throw new Error(
93
+ 'encryptOtpAttempt: enclave quorum public key does not match pinned signing key',
94
+ )
95
+ }
96
+
97
+ const dataBytes = hexToBytes(envelope.data)
98
+ const signatureBytes = hexToBytes(envelope.dataSignature)
99
+ const signerPublicKeyBytes = hexToBytes(envelope.enclaveQuorumPublic)
100
+
101
+ // The Go side does sha256(data) then ASN.1 DER ECDSA verify, without
102
+ // enforcing low-S. Match that here.
103
+ const valid = p256.verify(signatureBytes, dataBytes, signerPublicKeyBytes, {
104
+ prehash: true,
105
+ format: 'der',
106
+ lowS: false,
107
+ })
108
+ if (!valid) {
109
+ throw new Error('encryptOtpAttempt: invalid enclave signature on bundle')
110
+ }
111
+
112
+ let signedData: SignedTargetData
113
+ try {
114
+ signedData = JSON.parse(new TextDecoder().decode(dataBytes))
115
+ } catch (err) {
116
+ throw new Error(
117
+ `encryptOtpAttempt: failed to parse signed bundle data: ${(err as Error).message}`,
118
+ )
119
+ }
120
+ if (!signedData.targetPublic) {
121
+ throw new Error('encryptOtpAttempt: missing targetPublic in signed data')
122
+ }
123
+
124
+ const targetPublicKey = hexToBytes(signedData.targetPublic)
125
+
126
+ // Plaintext shape matches what the Go example marshals:
127
+ // { otp_code: string, public_key: string }
128
+ const plaintext = new TextEncoder().encode(
129
+ JSON.stringify({ otp_code: otpCode, public_key: publicKey }),
130
+ )
131
+
132
+ const { encappedPublic, ciphertext } = await hpkeSealP256({
133
+ receiverPublicKey: targetPublicKey,
134
+ plaintext,
135
+ })
136
+
137
+ // Wire format = the Go SDK's `ClientSendMsg`: Bytes fields hex-encoded.
138
+ return JSON.stringify({
139
+ encappedPublic: bytesToHex(encappedPublic),
140
+ ciphertext: bytesToHex(ciphertext),
141
+ })
142
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * HPKE (RFC 9180) seal for Turnkey enclave-encrypted requests.
3
+ *
4
+ * Suite: DHKEM(P-256, HKDF-SHA256) / HKDF-SHA256 / AES-256-GCM
5
+ * - KEM ID = 0x0010 (DHKEM-P256-HKDF-SHA256)
6
+ * - KDF ID = 0x0001 (HKDF-SHA256)
7
+ * - AEAD ID = 0x0002 (AES-256-GCM)
8
+ *
9
+ * Wire format and AAD construction match Turnkey's enclave_encrypt Go package:
10
+ * info = "turnkey_hpke"
11
+ * aad = enc || pkR (both 65-byte uncompressed P-256 points)
12
+ *
13
+ * References:
14
+ * - RFC 9180 §4 / §5
15
+ * - tkhq/go-sdk/pkg/enclave_encrypt
16
+ */
17
+
18
+ import { p256 } from '@noble/curves/nist.js'
19
+ import { expand, extract } from '@noble/hashes/hkdf.js'
20
+ import { sha256 } from '@noble/hashes/sha2.js'
21
+
22
+ const KEM_ID = 0x0010
23
+ const KDF_ID = 0x0001
24
+ const AEAD_ID = 0x0002
25
+
26
+ // Output sizes for the chosen primitives.
27
+ const NH = 32 // SHA-256 output
28
+ const NK = 32 // AES-256 key
29
+ const NN = 12 // AES-GCM nonce
30
+ const NPK = 65 // uncompressed P-256 point: 0x04 || X || Y
31
+
32
+ const TURNKEY_HPKE_INFO = new TextEncoder().encode('turnkey_hpke')
33
+
34
+ const HPKE_VERSION = new TextEncoder().encode('HPKE-v1')
35
+
36
+ // suite_id for the HPKE context: "HPKE" || I2OSP(KEM,2) || I2OSP(KDF,2) || I2OSP(AEAD,2)
37
+ const HPKE_SUITE_ID = concat(
38
+ new TextEncoder().encode('HPKE'),
39
+ i2osp(KEM_ID, 2),
40
+ i2osp(KDF_ID, 2),
41
+ i2osp(AEAD_ID, 2),
42
+ )
43
+
44
+ // suite_id for the KEM scope: "KEM" || I2OSP(KEM,2)
45
+ const KEM_SUITE_ID = concat(new TextEncoder().encode('KEM'), i2osp(KEM_ID, 2))
46
+
47
+ function concat(...parts: Uint8Array[]): Uint8Array {
48
+ const total = parts.reduce((sum, p) => sum + p.length, 0)
49
+ const out = new Uint8Array(total)
50
+ let offset = 0
51
+ for (const p of parts) {
52
+ out.set(p, offset)
53
+ offset += p.length
54
+ }
55
+ return out
56
+ }
57
+
58
+ function i2osp(n: number, len: number): Uint8Array {
59
+ const out = new Uint8Array(len)
60
+ for (let i = len - 1; i >= 0; i--) {
61
+ out[i] = n & 0xff
62
+ n >>>= 8
63
+ }
64
+ return out
65
+ }
66
+
67
+ // LabeledExtract(salt, label, ikm, suite_id) =
68
+ // HKDF-Extract(salt, "HPKE-v1" || suite_id || label || ikm)
69
+ function labeledExtract(
70
+ salt: Uint8Array,
71
+ label: string,
72
+ ikm: Uint8Array,
73
+ suiteId: Uint8Array,
74
+ ): Uint8Array {
75
+ const labeledIkm = concat(
76
+ HPKE_VERSION,
77
+ suiteId,
78
+ new TextEncoder().encode(label),
79
+ ikm,
80
+ )
81
+ return extract(sha256, labeledIkm, salt)
82
+ }
83
+
84
+ // LabeledExpand(prk, label, info, L, suite_id) =
85
+ // HKDF-Expand(prk, I2OSP(L,2) || "HPKE-v1" || suite_id || label || info, L)
86
+ function labeledExpand(
87
+ prk: Uint8Array,
88
+ label: string,
89
+ info: Uint8Array,
90
+ length: number,
91
+ suiteId: Uint8Array,
92
+ ): Uint8Array {
93
+ const labeledInfo = concat(
94
+ i2osp(length, 2),
95
+ HPKE_VERSION,
96
+ suiteId,
97
+ new TextEncoder().encode(label),
98
+ info,
99
+ )
100
+ return expand(sha256, prk, labeledInfo, length)
101
+ }
102
+
103
+ // DHKEM Encap: returns (sharedSecret, enc)
104
+ // sharedSecret is 32 bytes; enc is the serialized ephemeral pubkey (65 bytes uncompressed).
105
+ function encap(receiverPublicKey: Uint8Array): {
106
+ sharedSecret: Uint8Array
107
+ enc: Uint8Array
108
+ } {
109
+ const ephSk = p256.utils.randomSecretKey()
110
+ const ephPkUncompressed = p256.getPublicKey(ephSk, false)
111
+
112
+ // ECDH: returns the serialized shared point. Pass isCompressed=true so the
113
+ // first byte is the SEC1 prefix and bytes [1, 33) are the x-coordinate.
114
+ const sharedPoint = p256.getSharedSecret(
115
+ ephSk,
116
+ receiverPublicKey,
117
+ /* isCompressed */ true,
118
+ )
119
+ const dh = sharedPoint.slice(1, 33)
120
+
121
+ const kemContext = concat(ephPkUncompressed, receiverPublicKey)
122
+
123
+ const eaePrk = labeledExtract(new Uint8Array(0), 'eae_prk', dh, KEM_SUITE_ID)
124
+ const sharedSecret = labeledExpand(
125
+ eaePrk,
126
+ 'shared_secret',
127
+ kemContext,
128
+ NH,
129
+ KEM_SUITE_ID,
130
+ )
131
+
132
+ return { sharedSecret, enc: ephPkUncompressed }
133
+ }
134
+
135
+ // KeySchedule for mode_base: returns (key, base_nonce).
136
+ function keySchedule(
137
+ sharedSecret: Uint8Array,
138
+ info: Uint8Array,
139
+ ): { key: Uint8Array; baseNonce: Uint8Array } {
140
+ const empty = new Uint8Array(0)
141
+
142
+ const pskIdHash = labeledExtract(empty, 'psk_id_hash', empty, HPKE_SUITE_ID)
143
+ const infoHash = labeledExtract(empty, 'info_hash', info, HPKE_SUITE_ID)
144
+
145
+ // mode_base = 0x00 prepended to (psk_id_hash || info_hash)
146
+ const keyScheduleContext = concat(new Uint8Array([0]), pskIdHash, infoHash)
147
+
148
+ const secret = labeledExtract(sharedSecret, 'secret', empty, HPKE_SUITE_ID)
149
+
150
+ const key = labeledExpand(
151
+ secret,
152
+ 'key',
153
+ keyScheduleContext,
154
+ NK,
155
+ HPKE_SUITE_ID,
156
+ )
157
+ const baseNonce = labeledExpand(
158
+ secret,
159
+ 'base_nonce',
160
+ keyScheduleContext,
161
+ NN,
162
+ HPKE_SUITE_ID,
163
+ )
164
+
165
+ return { key, baseNonce }
166
+ }
167
+
168
+ // Web Crypto's BufferSource type rejects `Uint8Array<ArrayBufferLike>` (which
169
+ // noble/v2 returns) under strict TS lib settings because the underlying buffer
170
+ // could in principle be a SharedArrayBuffer. Copy into a fresh ArrayBuffer to
171
+ // satisfy the type.
172
+ function toArrayBuffer(u8: Uint8Array): ArrayBuffer {
173
+ const out = new ArrayBuffer(u8.byteLength)
174
+ new Uint8Array(out).set(u8)
175
+ return out
176
+ }
177
+
178
+ async function aesGcmSeal(
179
+ key: Uint8Array,
180
+ nonce: Uint8Array,
181
+ aad: Uint8Array,
182
+ plaintext: Uint8Array,
183
+ ): Promise<Uint8Array> {
184
+ // Web Crypto returns ciphertext || tag (16 bytes appended). Matches the
185
+ // single-blob format Turnkey's `Sealer.Seal` produces.
186
+ const cryptoKey = await crypto.subtle.importKey(
187
+ 'raw',
188
+ toArrayBuffer(key),
189
+ { name: 'AES-GCM' },
190
+ /* extractable */ false,
191
+ ['encrypt'],
192
+ )
193
+ const ct = await crypto.subtle.encrypt(
194
+ {
195
+ name: 'AES-GCM',
196
+ iv: toArrayBuffer(nonce),
197
+ additionalData: toArrayBuffer(aad),
198
+ tagLength: 128,
199
+ },
200
+ cryptoKey,
201
+ toArrayBuffer(plaintext),
202
+ )
203
+ return new Uint8Array(ct)
204
+ }
205
+
206
+ export type HpkeSealResult = {
207
+ /** Ephemeral sender public key (uncompressed P-256, 65 bytes). */
208
+ encappedPublic: Uint8Array
209
+ /** AES-256-GCM ciphertext with a 16-byte authentication tag appended. */
210
+ ciphertext: Uint8Array
211
+ }
212
+
213
+ /**
214
+ * Single-shot HPKE seal in mode_base for Turnkey's TLS Fetcher enclave.
215
+ *
216
+ * Uses the fixed Turnkey `info = "turnkey_hpke"` and the AAD shape
217
+ * `enc || receiverPublicKey` so the resulting bundle is decryptable by
218
+ * `enclave_encrypt.EnclaveEncryptServer.Decrypt`.
219
+ *
220
+ * @param receiverPublicKey - The enclave's ephemeral target public key
221
+ * (uncompressed P-256, 65 bytes), extracted from the encryption target bundle.
222
+ * @param plaintext - The bytes to encrypt (e.g. the JSON-encoded OTP attempt).
223
+ */
224
+ export async function hpkeSealP256({
225
+ receiverPublicKey,
226
+ plaintext,
227
+ }: {
228
+ receiverPublicKey: Uint8Array
229
+ plaintext: Uint8Array
230
+ }): Promise<HpkeSealResult> {
231
+ if (receiverPublicKey.length !== NPK) {
232
+ throw new Error(
233
+ `hpkeSealP256: receiverPublicKey must be ${NPK} bytes (uncompressed P-256), got ${receiverPublicKey.length}`,
234
+ )
235
+ }
236
+
237
+ const { sharedSecret, enc } = encap(receiverPublicKey)
238
+ const { key, baseNonce } = keySchedule(sharedSecret, TURNKEY_HPKE_INFO)
239
+
240
+ // First message of the context, sequence 0 → nonce = base_nonce.
241
+ const aad = concat(enc, receiverPublicKey)
242
+ const ciphertext = await aesGcmSeal(key, baseNonce, aad, plaintext)
243
+
244
+ return { encappedPublic: enc, ciphertext }
245
+ }