@ramonclaudio/create-vexpo 0.1.0 → 0.1.1

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 (174) hide show
  1. package/README.md +10 -10
  2. package/dist/index.js +8 -7
  3. package/dist/templates/default/.eas/workflows/asc-events.yml +9 -6
  4. package/dist/templates/default/.eas/workflows/deploy-production.yml +28 -15
  5. package/dist/templates/default/.eas/workflows/e2e-tests.yml +3 -2
  6. package/dist/templates/default/.eas/workflows/pr-preview.yml +12 -21
  7. package/dist/templates/default/.eas/workflows/release.yml +3 -7
  8. package/dist/templates/default/.eas/workflows/rollback.yml +54 -28
  9. package/dist/templates/default/.eas/workflows/rollout.yml +27 -33
  10. package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +1 -5
  11. package/dist/templates/default/.eas/workflows/testflight.yml +3 -7
  12. package/dist/templates/default/.github/workflows/check.yml +20 -12
  13. package/dist/templates/default/.maestro/launch.yaml +19 -10
  14. package/dist/templates/default/AGENTS.md +25 -8
  15. package/dist/templates/default/DESIGN.md +14 -10
  16. package/dist/templates/default/README.md +83 -78
  17. package/dist/templates/default/SETUP.md +159 -152
  18. package/dist/templates/default/__tests__/convex/_auth-harness.test.ts +112 -0
  19. package/dist/templates/default/__tests__/convex/_harness.ts +132 -0
  20. package/dist/templates/default/__tests__/convex/appAttest.test.ts +172 -0
  21. package/dist/templates/default/__tests__/convex/appAttestStore.test.ts +48 -0
  22. package/dist/templates/default/__tests__/convex/pushTokens-remove.test.ts +106 -0
  23. package/dist/templates/default/__tests__/convex/pushTokens-upsert.test.ts +146 -0
  24. package/dist/templates/default/__tests__/convex/users-deleteAccount.test.ts +140 -0
  25. package/dist/templates/default/__tests__/convex/users-deleteAvatar.test.ts +104 -0
  26. package/dist/templates/default/__tests__/convex/users-getMe.test.ts +98 -0
  27. package/dist/templates/default/__tests__/convex/users-getUser.test.ts +120 -0
  28. package/dist/templates/default/__tests__/convex/users-hardDeleteExpired.test.ts +67 -0
  29. package/dist/templates/default/__tests__/convex/users-restoreAccount.test.ts +96 -0
  30. package/dist/templates/default/__tests__/convex/users-updateAvatar.test.ts +92 -0
  31. package/dist/templates/default/__tests__/convex/users-updateProfile.test.ts +126 -0
  32. package/dist/templates/default/__tests__/convex/webhook.test.ts +31 -0
  33. package/dist/templates/default/__tests__/lib/deep-link.test.ts +51 -6
  34. package/dist/templates/default/__tests__/lib/schemas.test.ts +205 -0
  35. package/dist/templates/default/__tests__/lib/text-style.test.ts +31 -0
  36. package/dist/templates/default/_env.example +7 -7
  37. package/dist/templates/default/_gitattributes +1 -1
  38. package/dist/templates/default/_gitignore +17 -2
  39. package/dist/templates/default/_npmrc +7 -0
  40. package/dist/templates/default/_oxlintrc.json +1 -1
  41. package/dist/templates/default/app-store/accessibility.config.json +20 -0
  42. package/dist/templates/default/app-store/privacy.config.json +27 -0
  43. package/dist/templates/default/app.config.ts +105 -33
  44. package/dist/templates/default/app.json +1 -9
  45. package/dist/templates/default/convex/_generated/api.d.ts +12 -0
  46. package/dist/templates/default/convex/admin.ts +0 -13
  47. package/dist/templates/default/convex/appAttest.ts +467 -0
  48. package/dist/templates/default/convex/appAttestStore.ts +141 -0
  49. package/dist/templates/default/convex/apple.ts +53 -0
  50. package/dist/templates/default/convex/auth.ts +6 -45
  51. package/dist/templates/default/convex/constants.ts +2 -7
  52. package/dist/templates/default/convex/crons.ts +12 -5
  53. package/dist/templates/default/convex/email.ts +4 -24
  54. package/dist/templates/default/convex/env.ts +0 -4
  55. package/dist/templates/default/convex/errors.ts +0 -7
  56. package/dist/templates/default/convex/functions.ts +0 -26
  57. package/dist/templates/default/convex/http.ts +3 -5
  58. package/dist/templates/default/convex/log.ts +2 -25
  59. package/dist/templates/default/convex/pushSender.ts +145 -0
  60. package/dist/templates/default/convex/pushTokens.ts +110 -13
  61. package/dist/templates/default/convex/rateLimit.ts +8 -39
  62. package/dist/templates/default/convex/schema.ts +48 -5
  63. package/dist/templates/default/convex/tsconfig.json +1 -0
  64. package/dist/templates/default/convex/users.ts +143 -61
  65. package/dist/templates/default/convex/validators.ts +1 -38
  66. package/dist/templates/default/convex/webhook.ts +1 -31
  67. package/dist/templates/default/convex.json +1 -2
  68. package/dist/templates/default/metro.config.js +9 -1
  69. package/dist/templates/default/package.json +67 -70
  70. package/dist/templates/default/plugins/README.md +5 -1
  71. package/dist/templates/default/scripts/README.md +9 -9
  72. package/dist/templates/default/scripts/_run.mjs +3 -20
  73. package/dist/templates/default/scripts/clean.ts +81 -69
  74. package/dist/templates/default/scripts/gen-update-cert.mjs +98 -0
  75. package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home)/index.tsx +21 -6
  76. package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home,search)/_layout.tsx +9 -8
  77. package/dist/templates/default/{app → src/app}/(app)/(tabs)/(search)/index.tsx +26 -24
  78. package/dist/templates/default/{app → src/app}/(app)/(tabs)/_layout.tsx +3 -4
  79. package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/_layout.tsx +10 -6
  80. package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/index.tsx +81 -51
  81. package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/preferences.tsx +72 -12
  82. package/dist/templates/default/src/app/(app)/_layout.tsx +147 -0
  83. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/_layout.tsx +4 -5
  84. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/forgot-password.tsx +15 -9
  85. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/reset-password.tsx +88 -14
  86. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-in.tsx +65 -35
  87. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-up.tsx +131 -196
  88. package/dist/templates/default/src/app/(app)/debug.tsx +479 -0
  89. package/dist/templates/default/{app → src/app}/(app)/help.tsx +76 -64
  90. package/dist/templates/default/{app → src/app}/(app)/linked.tsx +21 -27
  91. package/dist/templates/default/{app → src/app}/(app)/privacy.tsx +35 -8
  92. package/dist/templates/default/src/app/(app)/profile/change-password.tsx +264 -0
  93. package/dist/templates/default/{app/(app)/profile.tsx → src/app/(app)/profile/index.tsx} +179 -255
  94. package/dist/templates/default/src/app/(app)/restore-account.tsx +192 -0
  95. package/dist/templates/default/src/app/(app)/sessions.tsx +287 -0
  96. package/dist/templates/default/src/app/(app)/welcome.tsx +194 -0
  97. package/dist/templates/default/src/app/+native-intent.tsx +25 -0
  98. package/dist/templates/default/src/app/+not-found.tsx +43 -0
  99. package/dist/templates/default/{app → src/app}/_layout.tsx +28 -37
  100. package/dist/templates/default/src/components/auth/apple-button.tsx +51 -0
  101. package/dist/templates/default/{components → src/components}/auth/otp-verification.tsx +79 -58
  102. package/dist/templates/default/{components → src/components}/auth/password-field.tsx +74 -18
  103. package/dist/templates/default/src/components/auth/segmented-toggle.tsx +71 -0
  104. package/dist/templates/default/src/components/ui/content-unavailable.tsx +81 -0
  105. package/dist/templates/default/src/components/ui/convex-error.tsx +21 -0
  106. package/dist/templates/default/src/components/ui/error-boundary.tsx +89 -0
  107. package/dist/templates/default/{components → src/components}/ui/loading-screen.tsx +5 -4
  108. package/dist/templates/default/{components → src/components}/ui/material.tsx +50 -17
  109. package/dist/templates/default/src/components/ui/offline-banner.tsx +59 -0
  110. package/dist/templates/default/{components → src/components}/ui/prominent-button.tsx +8 -11
  111. package/dist/templates/default/{components → src/components}/ui/skeleton.tsx +31 -13
  112. package/dist/templates/default/src/components/ui/status-text.tsx +64 -0
  113. package/dist/templates/default/src/components/ui/update-banner.tsx +85 -0
  114. package/dist/templates/default/{constants → src/constants}/layout.ts +0 -6
  115. package/dist/templates/default/{constants → src/constants}/theme.ts +49 -64
  116. package/dist/templates/default/{constants → src/constants}/ui.ts +13 -4
  117. package/dist/templates/default/src/hooks/use-debounce.ts +12 -0
  118. package/dist/templates/default/src/hooks/use-deep-link.ts +51 -0
  119. package/dist/templates/default/src/hooks/use-delete-account.ts +35 -0
  120. package/dist/templates/default/src/hooks/use-motion-screen-options.ts +13 -0
  121. package/dist/templates/default/src/hooks/use-network.ts +34 -0
  122. package/dist/templates/default/{hooks → src/hooks}/use-notifications.ts +39 -30
  123. package/dist/templates/default/src/hooks/use-reduce-transparency.ts +30 -0
  124. package/dist/templates/default/{hooks → src/hooks}/use-theme.ts +0 -5
  125. package/dist/templates/default/src/lib/appAttest.ts +78 -0
  126. package/dist/templates/default/src/lib/assets.ts +9 -0
  127. package/dist/templates/default/src/lib/deep-link.ts +82 -0
  128. package/dist/templates/default/{lib → src/lib}/dev-menu.ts +0 -4
  129. package/dist/templates/default/{lib → src/lib}/device.ts +1 -13
  130. package/dist/templates/default/{lib → src/lib}/dynamic-font.ts +13 -10
  131. package/dist/templates/default/src/lib/dynamic-symbol-size.ts +33 -0
  132. package/dist/templates/default/src/lib/masks.ts +21 -0
  133. package/dist/templates/default/src/lib/native-state.ts +20 -0
  134. package/dist/templates/default/{lib → src/lib}/notifications.ts +7 -45
  135. package/dist/templates/default/{lib → src/lib}/preferences.ts +0 -2
  136. package/dist/templates/default/{lib → src/lib}/schemas.ts +19 -16
  137. package/dist/templates/default/src/lib/text-style.ts +20 -0
  138. package/dist/templates/default/{lib → src/lib}/updates.ts +0 -7
  139. package/dist/templates/default/store.config.json +1 -1
  140. package/dist/templates/default/tsconfig.json +3 -1
  141. package/dist/templates/default/vitest.config.ts +8 -1
  142. package/package.json +5 -5
  143. package/dist/templates/default/app/(app)/_layout.tsx +0 -73
  144. package/dist/templates/default/app/(app)/debug.tsx +0 -389
  145. package/dist/templates/default/app/(app)/sessions.tsx +0 -191
  146. package/dist/templates/default/app/(app)/welcome.tsx +0 -140
  147. package/dist/templates/default/app/+native-intent.tsx +0 -14
  148. package/dist/templates/default/app/+not-found.tsx +0 -51
  149. package/dist/templates/default/bun.lock +0 -1860
  150. package/dist/templates/default/components/auth/segmented-toggle.tsx +0 -47
  151. package/dist/templates/default/components/ui/convex-error.tsx +0 -32
  152. package/dist/templates/default/components/ui/error-boundary.tsx +0 -57
  153. package/dist/templates/default/components/ui/offline-banner.tsx +0 -58
  154. package/dist/templates/default/components/ui/status-text.tsx +0 -49
  155. package/dist/templates/default/components/ui/update-banner.tsx +0 -82
  156. package/dist/templates/default/fingerprint.config.js +0 -9
  157. package/dist/templates/default/hooks/use-debounce.ts +0 -20
  158. package/dist/templates/default/hooks/use-deep-link.ts +0 -43
  159. package/dist/templates/default/hooks/use-network.ts +0 -11
  160. package/dist/templates/default/lib/assets.ts +0 -17
  161. package/dist/templates/default/lib/deep-link.ts +0 -71
  162. package/dist/templates/default/patches/PR-368.patch +0 -91
  163. package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
  164. /package/dist/templates/default/{hooks → src/hooks}/use-navigation-tracking.ts +0 -0
  165. /package/dist/templates/default/{hooks → src/hooks}/use-onboarding.ts +0 -0
  166. /package/dist/templates/default/{hooks → src/hooks}/use-reduced-motion.ts +0 -0
  167. /package/dist/templates/default/{hooks → src/hooks}/use-updates.ts +0 -0
  168. /package/dist/templates/default/{lib → src/lib}/a11y.ts +0 -0
  169. /package/dist/templates/default/{lib → src/lib}/app.ts +0 -0
  170. /package/dist/templates/default/{lib → src/lib}/auth-client.ts +0 -0
  171. /package/dist/templates/default/{lib → src/lib}/convex-auth.tsx +0 -0
  172. /package/dist/templates/default/{lib → src/lib}/env.ts +0 -0
  173. /package/dist/templates/default/{lib → src/lib}/haptics.ts +0 -0
  174. /package/dist/templates/default/{lib → src/lib}/storage.ts +0 -0
@@ -0,0 +1,467 @@
1
+ "use node";
2
+
3
+ import { X509Certificate, createHash, createPublicKey, randomBytes, verify } from "node:crypto";
4
+
5
+ import { decode as cborDecode } from "cbor-x";
6
+ import { v } from "convex/values";
7
+
8
+ import { internal } from "./_generated/api";
9
+ import { internalAction } from "./_generated/server";
10
+
11
+ /**
12
+ * Apple App Attest verifier.
13
+ *
14
+ * Implements every step of Apple's attestation + assertion protocol so a
15
+ * Convex action can prove that incoming requests came from a real, unmodified
16
+ * vexpo binary running on a real iOS device with a Secure Enclave.
17
+ *
18
+ * Protocol (per Apple's "Validating Apps That Connect to Your Server"):
19
+ *
20
+ * ATTESTATION (one-time per device)
21
+ * 1. Server issues a 32-byte random `challenge`.
22
+ * 2. Client calls `generateKeyAsync()` → keyId.
23
+ * 3. Client calls `attestKeyAsync(keyId, sha256(challenge))` → CBOR
24
+ * attestation containing `{ fmt: 'apple-appattest', attStmt: { x5c,
25
+ * receipt }, authData }`.
26
+ * 4. Server (this module) verifies:
27
+ * a. cert chain `x5c[0]` → `x5c[1]` → Apple App Attest Root CA
28
+ * b. compute nonce = sha256(authData || sha256(challenge)); verify
29
+ * it matches the leaf cert's `1.2.840.113635.100.8.2` extension
30
+ * c. hash the leaf cert's public key; verify it matches the
31
+ * `credentialId` portion of authData
32
+ * d. authData.rpIdHash == sha256("<TEAM_ID>.<BUNDLE_ID>")
33
+ * e. authData.aaguid matches the expected production or dev value
34
+ * f. authData.counter == 0
35
+ * 5. Server stores `{ keyId, publicKey, counter: 0, environment }`.
36
+ *
37
+ * ASSERTION (per signed request)
38
+ * 1. Client calls `generateAssertionAsync(keyId, sha256(payload))` →
39
+ * CBOR assertion containing `{ signature, authenticatorData }`.
40
+ * 2. Server (this module) verifies:
41
+ * a. `signature` is a valid ECDSA-P256-SHA256 signature over
42
+ * sha256(authenticatorData || sha256(payload)) using the stored
43
+ * public key
44
+ * b. authenticatorData.rpIdHash matches the expected value
45
+ * c. authenticatorData.counter > stored counter
46
+ * 3. Server bumps the stored counter.
47
+ *
48
+ * https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server
49
+ */
50
+
51
+ // Apple App Attest Root CA, distributed by Apple at
52
+ // https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem
53
+ // Pinned in source so a compromised CDN can't replace it.
54
+ const APPLE_ROOT_CA = `-----BEGIN CERTIFICATE-----
55
+ MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw
56
+ JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK
57
+ QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa
58
+ Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv
59
+ biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y
60
+ bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh
61
+ NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au
62
+ Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/
63
+ MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw
64
+ CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn
65
+ 53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV
66
+ oyFraWVIyd/dganmrduC1bmTBGwD
67
+ -----END CERTIFICATE-----`;
68
+
69
+ const APPLE_ROOT_X509 = new X509Certificate(APPLE_ROOT_CA);
70
+
71
+ const APPLE_NONCE_OID = "1.2.840.113635.100.8.2";
72
+
73
+ // AAGUID is 16 bytes. Production runs ship "appattest" + 7 NULs. Development
74
+ // (simulator-style attestation, only valid when Xcode debugger is attached
75
+ // to the app) ships "appattestdevelop". A production app should never see
76
+ // the dev AAGUID; we reject it unless the deployment is explicitly running
77
+ // in development mode.
78
+ const AAGUID_PRODUCTION = Buffer.from("appattest\0\0\0\0\0\0\0", "binary");
79
+ const AAGUID_DEVELOPMENT = Buffer.from("appattestdevelop", "binary");
80
+
81
+ // Challenges TTL'd so a captured nonce can't be replayed indefinitely.
82
+ const CHALLENGE_TTL_MS = 5 * 60 * 1000;
83
+
84
+ /**
85
+ * Single-use: each nonce is consumed on the first verification that
86
+ * references it. Expired nonces are swept by `cleanupAppAttestChallenges`.
87
+ */
88
+ export const issueChallenge = internalAction({
89
+ args: {},
90
+ returns: v.object({ nonce: v.string(), expiresAt: v.number() }),
91
+ handler: async (ctx) => {
92
+ const nonce = randomBytes(32).toString("base64url");
93
+ const expiresAt = Date.now() + CHALLENGE_TTL_MS;
94
+ await ctx.runMutation(internal.appAttestStore.createChallenge, { nonce, expiresAt });
95
+ return { nonce, expiresAt };
96
+ },
97
+ });
98
+
99
+ export const verifyAttestation = internalAction({
100
+ args: {
101
+ keyId: v.string(),
102
+ attestation: v.string(),
103
+ challenge: v.string(),
104
+ bundleId: v.optional(v.string()),
105
+ teamId: v.optional(v.string()),
106
+ userId: v.optional(v.id("users")),
107
+ },
108
+ returns: v.object({
109
+ keyId: v.string(),
110
+ publicKey: v.string(),
111
+ environment: v.union(v.literal("development"), v.literal("production")),
112
+ }),
113
+ handler: async (ctx, args) => {
114
+ const consumed = await ctx.runMutation(internal.appAttestStore.consumeChallenge, {
115
+ nonce: args.challenge,
116
+ now: Date.now(),
117
+ });
118
+ if (!consumed) {
119
+ throw new Error("app-attest: challenge unknown, expired, or already consumed");
120
+ }
121
+
122
+ const bundleId = args.bundleId ?? process.env.APP_BUNDLE_ID;
123
+ const teamId = args.teamId ?? process.env.APPLE_TEAM_ID;
124
+ if (!bundleId || !teamId) {
125
+ throw new Error("app-attest: APP_BUNDLE_ID and APPLE_TEAM_ID must be set");
126
+ }
127
+
128
+ const environment = decideEnvironment();
129
+ const result = verifyAttestationBytes({
130
+ keyId: args.keyId,
131
+ attestation: Buffer.from(args.attestation, "base64"),
132
+ challenge: args.challenge,
133
+ bundleId,
134
+ teamId,
135
+ environment,
136
+ });
137
+
138
+ await ctx.runMutation(internal.appAttestStore.storeKey, {
139
+ keyId: args.keyId,
140
+ publicKey: result.publicKey,
141
+ environment: result.environment,
142
+ userId: args.userId,
143
+ now: Date.now(),
144
+ });
145
+
146
+ return { keyId: args.keyId, publicKey: result.publicKey, environment: result.environment };
147
+ },
148
+ });
149
+
150
+ export const verifyAssertion = internalAction({
151
+ args: {
152
+ keyId: v.string(),
153
+ assertion: v.string(),
154
+ payload: v.string(),
155
+ bundleId: v.optional(v.string()),
156
+ teamId: v.optional(v.string()),
157
+ },
158
+ returns: v.object({ counter: v.number() }),
159
+ handler: async (ctx, args) => {
160
+ const key = await ctx.runQuery(internal.appAttestStore.findKey, { keyId: args.keyId });
161
+ if (!key) throw new Error("app-attest: unknown keyId");
162
+
163
+ const bundleId = args.bundleId ?? process.env.APP_BUNDLE_ID;
164
+ const teamId = args.teamId ?? process.env.APPLE_TEAM_ID;
165
+ if (!bundleId || !teamId) {
166
+ throw new Error("app-attest: APP_BUNDLE_ID and APPLE_TEAM_ID must be set");
167
+ }
168
+
169
+ const newCounter = verifyAssertionBytes({
170
+ assertion: Buffer.from(args.assertion, "base64"),
171
+ payload: args.payload,
172
+ bundleId,
173
+ teamId,
174
+ publicKey: Buffer.from(key.publicKey, "base64url"),
175
+ storedCounter: key.counter,
176
+ });
177
+
178
+ await ctx.runMutation(internal.appAttestStore.bumpCounter, {
179
+ keyId: args.keyId,
180
+ counter: newCounter,
181
+ });
182
+
183
+ return { counter: newCounter };
184
+ },
185
+ });
186
+
187
+ type AttestationInputs = {
188
+ keyId: string;
189
+ attestation: Buffer;
190
+ challenge: string;
191
+ bundleId: string;
192
+ teamId: string;
193
+ environment: "development" | "production";
194
+ };
195
+
196
+ type AttestationResult = {
197
+ publicKey: string;
198
+ environment: "development" | "production";
199
+ };
200
+
201
+ export function verifyAttestationBytes(inputs: AttestationInputs): AttestationResult {
202
+ const decoded = cborDecode(inputs.attestation) as {
203
+ fmt?: string;
204
+ attStmt?: { x5c?: Buffer[]; receipt?: Buffer };
205
+ authData?: Buffer;
206
+ };
207
+
208
+ if (decoded.fmt !== "apple-appattest") {
209
+ throw new Error(`app-attest: unexpected fmt '${decoded.fmt}'`);
210
+ }
211
+ const x5c = decoded.attStmt?.x5c;
212
+ const authData = decoded.authData;
213
+ if (!x5c || x5c.length < 2 || !authData) {
214
+ throw new Error("app-attest: missing x5c or authData");
215
+ }
216
+
217
+ // Step 1: cert chain. credCert is x5c[0], intermediate is x5c[1] (and
218
+ // possibly more), root is Apple's root CA pinned above.
219
+ const credCert = new X509Certificate(x5c[0]);
220
+ const intermediate = new X509Certificate(x5c[1]);
221
+ if (!intermediate.verify(APPLE_ROOT_X509.publicKey)) {
222
+ throw new Error("app-attest: intermediate cert is not signed by Apple App Attest Root CA");
223
+ }
224
+ if (!credCert.verify(intermediate.publicKey)) {
225
+ throw new Error("app-attest: credential cert is not signed by the intermediate");
226
+ }
227
+ assertCertValidity(credCert);
228
+
229
+ // Step 2-3: compute the expected nonce.
230
+ const clientDataHash = sha256(Buffer.from(inputs.challenge, "utf8"));
231
+ const expectedNonce = sha256(Buffer.concat([authData, clientDataHash]));
232
+
233
+ // Step 4: read the leaf cert's nonce extension (1.2.840.113635.100.8.2)
234
+ // and check it matches the expected nonce.
235
+ const credExtNonce = extractAppleNonceExtension(credCert);
236
+ if (!credExtNonce || !timingSafeEqual(credExtNonce, expectedNonce)) {
237
+ throw new Error("app-attest: nonce mismatch");
238
+ }
239
+
240
+ // Step 5: the credential public key hash (SHA-256 of the DER
241
+ // SubjectPublicKeyInfo) should equal the credentialId portion of
242
+ // authData. We extract the key from the leaf cert, hash it, and
243
+ // compare to the keyId the client sent.
244
+ const credPubKeyDer = credCert.publicKey.export({ type: "spki", format: "der" }) as Buffer;
245
+ const credPubKeyHash = sha256(credPubKeyDer);
246
+ if (credPubKeyHash.toString("base64") !== inputs.keyId) {
247
+ throw new Error("app-attest: leaf cert public-key hash does not match keyId");
248
+ }
249
+
250
+ // Step 6-9: rpIdHash, AAGUID, counter, credId checks within authData.
251
+ const ad = parseAuthData(authData);
252
+
253
+ const expectedRpIdHash = sha256(Buffer.from(`${inputs.teamId}.${inputs.bundleId}`, "utf8"));
254
+ if (!timingSafeEqual(ad.rpIdHash, expectedRpIdHash)) {
255
+ throw new Error("app-attest: rpIdHash does not match TEAM_ID.BUNDLE_ID");
256
+ }
257
+
258
+ if (ad.counter !== 0) {
259
+ throw new Error("app-attest: attestation counter must be 0");
260
+ }
261
+
262
+ const aaguidOk =
263
+ inputs.environment === "production"
264
+ ? timingSafeEqual(ad.aaguid, AAGUID_PRODUCTION)
265
+ : timingSafeEqual(ad.aaguid, AAGUID_DEVELOPMENT) ||
266
+ timingSafeEqual(ad.aaguid, AAGUID_PRODUCTION);
267
+ if (!aaguidOk) {
268
+ throw new Error(
269
+ `app-attest: AAGUID does not match expected for environment '${inputs.environment}'`,
270
+ );
271
+ }
272
+
273
+ if (!ad.credId || ad.credId.toString("base64") !== inputs.keyId) {
274
+ throw new Error("app-attest: credentialId in authData does not match keyId");
275
+ }
276
+
277
+ return {
278
+ publicKey: credPubKeyDer.toString("base64url"),
279
+ environment: inputs.environment,
280
+ };
281
+ }
282
+
283
+ type AssertionInputs = {
284
+ assertion: Buffer;
285
+ payload: string;
286
+ bundleId: string;
287
+ teamId: string;
288
+ publicKey: Buffer; // SPKI DER
289
+ storedCounter: number;
290
+ };
291
+
292
+ export function verifyAssertionBytes(inputs: AssertionInputs): number {
293
+ const decoded = cborDecode(inputs.assertion) as {
294
+ signature?: Buffer;
295
+ authenticatorData?: Buffer;
296
+ };
297
+ const signature = decoded.signature;
298
+ const authData = decoded.authenticatorData;
299
+ if (!signature || !authData) {
300
+ throw new Error("app-attest: assertion missing signature or authenticatorData");
301
+ }
302
+
303
+ // Reconstruct what the device signed: SHA256(authData || SHA256(payload))
304
+ const clientDataHash = sha256(Buffer.from(inputs.payload, "utf8"));
305
+ const dataToSign = Buffer.concat([authData, clientDataHash]);
306
+ const hashedData = sha256(dataToSign);
307
+
308
+ // The public key in storage is DER SPKI; convert to a KeyObject for
309
+ // crypto.verify.
310
+ const pubKey = createPublicKey({ key: inputs.publicKey, format: "der", type: "spki" });
311
+
312
+ // Apple emits ECDSA in IEEE-P1363 r||s form, but Node's `verify` defaults
313
+ // to DER. Apple's actual output is DER though, per their docs ("This is a
314
+ // DER-encoded ASN.1 sequence"). Both formats are possible across iOS
315
+ // versions, so try DER first and fall back to IEEE-P1363.
316
+ const ok =
317
+ verify(null, hashedData, pubKey, signature) ||
318
+ verify(null, hashedData, { key: pubKey, dsaEncoding: "ieee-p1363" }, signature);
319
+ if (!ok) {
320
+ throw new Error("app-attest: assertion signature failed verification");
321
+ }
322
+
323
+ // rpIdHash and counter checks reuse the attestation parser; the layout
324
+ // is identical except attestedCredentialData isn't present.
325
+ const ad = parseAuthData(authData, { hasCredential: false });
326
+
327
+ const expectedRpIdHash = sha256(Buffer.from(`${inputs.teamId}.${inputs.bundleId}`, "utf8"));
328
+ if (!timingSafeEqual(ad.rpIdHash, expectedRpIdHash)) {
329
+ throw new Error("app-attest: assertion rpIdHash mismatch");
330
+ }
331
+ if (ad.counter <= inputs.storedCounter) {
332
+ throw new Error(
333
+ `app-attest: assertion counter ${ad.counter} not strictly greater than stored ${inputs.storedCounter}`,
334
+ );
335
+ }
336
+ return ad.counter;
337
+ }
338
+
339
+ function sha256(input: Buffer): Buffer {
340
+ return createHash("sha256").update(input).digest();
341
+ }
342
+
343
+ function timingSafeEqual(a: Buffer, b: Buffer): boolean {
344
+ if (a.length !== b.length) return false;
345
+ let diff = 0;
346
+ for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;
347
+ return diff === 0;
348
+ }
349
+
350
+ function assertCertValidity(cert: X509Certificate): void {
351
+ const now = Date.now();
352
+ const notBefore = Date.parse(cert.validFrom);
353
+ const notAfter = Date.parse(cert.validTo);
354
+ if (Number.isFinite(notBefore) && now < notBefore) {
355
+ throw new Error("app-attest: credential cert not yet valid");
356
+ }
357
+ if (Number.isFinite(notAfter) && now > notAfter) {
358
+ throw new Error("app-attest: credential cert expired");
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Read the Apple App Attest nonce extension. The extension value is a
364
+ * DER-encoded ASN.1 sequence:
365
+ * SEQUENCE { [1] OCTET STRING { <nonce bytes> } }
366
+ *
367
+ * Node's `X509Certificate` exposes the raw extension DER through `raw`,
368
+ * but not individual extensions. We pull the cert's DER bytes and walk
369
+ * to the extension by OID.
370
+ */
371
+ function extractAppleNonceExtension(cert: X509Certificate): Buffer | null {
372
+ const der = cert.raw;
373
+ const oidBytes = encodeOid(APPLE_NONCE_OID);
374
+ const start = der.indexOf(oidBytes);
375
+ if (start < 0) return null;
376
+ // After the OID, the extension is `[critical BOOLEAN] OCTET STRING { ... }`.
377
+ // We skip the OID, any optional BOOLEAN, and parse the OCTET STRING wrapper.
378
+ let i = start + oidBytes.length;
379
+ // Optional critical BOOLEAN.
380
+ if (der[i] === 0x01 && der[i + 1] === 0x01) i += 3;
381
+ // OCTET STRING wrapper holding the actual extension DER.
382
+ if (der[i] !== 0x04) return null;
383
+ const outerLen = readDerLength(der, i + 1);
384
+ if (!outerLen) return null;
385
+ let cursor = i + 1 + outerLen.headerBytes;
386
+ // Inner SEQUENCE.
387
+ if (der[cursor] !== 0x30) return null;
388
+ const innerLen = readDerLength(der, cursor + 1);
389
+ if (!innerLen) return null;
390
+ cursor += 1 + innerLen.headerBytes;
391
+ // Context-specific [1] tag = 0xa1, holding the OCTET STRING with the nonce.
392
+ if (der[cursor] !== 0xa1) return null;
393
+ const ctxLen = readDerLength(der, cursor + 1);
394
+ if (!ctxLen) return null;
395
+ cursor += 1 + ctxLen.headerBytes;
396
+ if (der[cursor] !== 0x04) return null;
397
+ const nonceLen = readDerLength(der, cursor + 1);
398
+ if (!nonceLen) return null;
399
+ cursor += 1 + nonceLen.headerBytes;
400
+ return Buffer.from(der.subarray(cursor, cursor + nonceLen.value));
401
+ }
402
+
403
+ function readDerLength(buf: Buffer, offset: number): { value: number; headerBytes: number } | null {
404
+ const first = buf[offset];
405
+ if (first === undefined) return null;
406
+ if (first < 0x80) return { value: first, headerBytes: 1 };
407
+ const lengthBytes = first & 0x7f;
408
+ if (lengthBytes === 0 || lengthBytes > 4) return null;
409
+ let value = 0;
410
+ for (let i = 0; i < lengthBytes; i++) value = (value << 8) | buf[offset + 1 + i]!;
411
+ return { value, headerBytes: 1 + lengthBytes };
412
+ }
413
+
414
+ function encodeOid(oid: string): Buffer {
415
+ // Minimal OID encoder: produces the DER value bytes (without the type
416
+ // tag). Used to locate the OID inside the cert's raw DER.
417
+ const parts = oid.split(".").map((p) => parseInt(p, 10));
418
+ if (parts.length < 2) throw new Error(`invalid OID: ${oid}`);
419
+ const bytes: number[] = [40 * parts[0]! + parts[1]!];
420
+ for (let i = 2; i < parts.length; i++) {
421
+ let n = parts[i]!;
422
+ const buf: number[] = [];
423
+ do {
424
+ buf.unshift(n & 0x7f);
425
+ n >>= 7;
426
+ } while (n > 0);
427
+ for (let j = 0; j < buf.length - 1; j++) buf[j]! |= 0x80;
428
+ bytes.push(...buf);
429
+ }
430
+ return Buffer.from([0x06, bytes.length, ...bytes]);
431
+ }
432
+
433
+ type ParsedAuthData = {
434
+ rpIdHash: Buffer;
435
+ flags: number;
436
+ counter: number;
437
+ aaguid: Buffer;
438
+ credId: Buffer | null;
439
+ credentialPublicKey: Buffer | null;
440
+ };
441
+
442
+ function parseAuthData(buf: Buffer, opts: { hasCredential?: boolean } = {}): ParsedAuthData {
443
+ const hasCredential = opts.hasCredential ?? true;
444
+ if (buf.length < 37) throw new Error("app-attest: authData too short");
445
+ const rpIdHash = Buffer.from(buf.subarray(0, 32));
446
+ const flags = buf[32]!;
447
+ const counter = buf.readUInt32BE(33);
448
+ let aaguid = Buffer.alloc(16);
449
+ let credId: Buffer | null = null;
450
+ let credentialPublicKey: Buffer | null = null;
451
+ if (hasCredential) {
452
+ if (buf.length < 55) throw new Error("app-attest: authData missing credential block");
453
+ aaguid = Buffer.from(buf.subarray(37, 53));
454
+ const credIdLen = buf.readUInt16BE(53);
455
+ if (buf.length < 55 + credIdLen) throw new Error("app-attest: authData credentialId overruns");
456
+ credId = Buffer.from(buf.subarray(55, 55 + credIdLen));
457
+ credentialPublicKey = Buffer.from(buf.subarray(55 + credIdLen));
458
+ }
459
+ return { rpIdHash, flags, counter, aaguid, credId, credentialPublicKey };
460
+ }
461
+
462
+ function decideEnvironment(): "development" | "production" {
463
+ // Fail closed: production is the default so a missing or misconfigured env var
464
+ // never silently downgrades to accepting the weaker development AAGUID. Opt
465
+ // into development explicitly (e.g. local `convex dev` testing a dev build).
466
+ return process.env.APP_ATTEST_ENVIRONMENT === "development" ? "development" : "production";
467
+ }
@@ -0,0 +1,141 @@
1
+ import { v } from "convex/values";
2
+
3
+ import { internal } from "./_generated/api";
4
+ import { internalMutation, internalQuery } from "./_generated/server";
5
+
6
+ const CLEANUP_BATCH = 200;
7
+
8
+ export const createChallenge = internalMutation({
9
+ args: { nonce: v.string(), expiresAt: v.number() },
10
+ returns: v.id("appAttestChallenges"),
11
+ handler: async (ctx, args) => {
12
+ return ctx.db.insert("appAttestChallenges", {
13
+ nonce: args.nonce,
14
+ expiresAt: args.expiresAt,
15
+ used: false,
16
+ });
17
+ },
18
+ });
19
+
20
+ /**
21
+ * Single-use challenge consumption. Returns true if the nonce existed,
22
+ * hadn't been used, and hadn't expired. Marks it consumed atomically
23
+ * inside the mutation so a concurrent attempt can't double-spend it.
24
+ */
25
+ export const consumeChallenge = internalMutation({
26
+ args: { nonce: v.string(), now: v.number() },
27
+ returns: v.boolean(),
28
+ handler: async (ctx, args) => {
29
+ const row = await ctx.db
30
+ .query("appAttestChallenges")
31
+ .withIndex("by_nonce", (q) => q.eq("nonce", args.nonce))
32
+ .unique();
33
+ if (!row) return false;
34
+ if (row.used) return false;
35
+ if (row.expiresAt < args.now) return false;
36
+ await ctx.db.patch(row._id, { used: true });
37
+ return true;
38
+ },
39
+ });
40
+
41
+ export const storeKey = internalMutation({
42
+ args: {
43
+ keyId: v.string(),
44
+ publicKey: v.string(),
45
+ environment: v.union(v.literal("development"), v.literal("production")),
46
+ userId: v.optional(v.id("users")),
47
+ now: v.number(),
48
+ },
49
+ returns: v.id("appAttestKeys"),
50
+ handler: async (ctx, args) => {
51
+ const existing = await ctx.db
52
+ .query("appAttestKeys")
53
+ .withIndex("by_keyId", (q) => q.eq("keyId", args.keyId))
54
+ .unique();
55
+ if (existing) {
56
+ await ctx.db.patch(existing._id, {
57
+ publicKey: args.publicKey,
58
+ environment: args.environment,
59
+ userId: args.userId ?? existing.userId,
60
+ counter: 0,
61
+ attestedAt: args.now,
62
+ });
63
+ return existing._id;
64
+ }
65
+ return ctx.db.insert("appAttestKeys", {
66
+ keyId: args.keyId,
67
+ publicKey: args.publicKey,
68
+ environment: args.environment,
69
+ userId: args.userId,
70
+ counter: 0,
71
+ attestedAt: args.now,
72
+ });
73
+ },
74
+ });
75
+
76
+ export const findKey = internalQuery({
77
+ args: { keyId: v.string() },
78
+ returns: v.union(
79
+ v.object({
80
+ keyId: v.string(),
81
+ publicKey: v.string(),
82
+ counter: v.number(),
83
+ environment: v.union(v.literal("development"), v.literal("production")),
84
+ userId: v.optional(v.id("users")),
85
+ }),
86
+ v.null(),
87
+ ),
88
+ handler: async (ctx, { keyId }) => {
89
+ const row = await ctx.db
90
+ .query("appAttestKeys")
91
+ .withIndex("by_keyId", (q) => q.eq("keyId", keyId))
92
+ .unique();
93
+ if (!row) return null;
94
+ return {
95
+ keyId: row.keyId,
96
+ publicKey: row.publicKey,
97
+ counter: row.counter,
98
+ environment: row.environment,
99
+ userId: row.userId,
100
+ };
101
+ },
102
+ });
103
+
104
+ export const bumpCounter = internalMutation({
105
+ args: { keyId: v.string(), counter: v.number() },
106
+ returns: v.null(),
107
+ handler: async (ctx, { keyId, counter }) => {
108
+ const row = await ctx.db
109
+ .query("appAttestKeys")
110
+ .withIndex("by_keyId", (q) => q.eq("keyId", keyId))
111
+ .unique();
112
+ if (!row) throw new Error("app-attest: bumpCounter against unknown keyId");
113
+ // Re-check monotonicity inside the mutation transaction so two
114
+ // assertions racing through different action invocations can't
115
+ // both win.
116
+ if (counter <= row.counter) {
117
+ throw new Error("app-attest: assertion counter regressed");
118
+ }
119
+ await ctx.db.patch(row._id, { counter });
120
+ return null;
121
+ },
122
+ });
123
+
124
+ export const cleanupChallenges = internalMutation({
125
+ args: {},
126
+ returns: v.number(),
127
+ handler: async (ctx) => {
128
+ const now = Date.now();
129
+ const batch = await ctx.db
130
+ .query("appAttestChallenges")
131
+ .withIndex("by_expiresAt")
132
+ .order("asc")
133
+ .take(CLEANUP_BATCH);
134
+ const expired = batch.filter((r) => r.expiresAt < now);
135
+ await Promise.all(expired.map((r) => ctx.db.delete(r._id)));
136
+ if (batch.length === CLEANUP_BATCH && expired.length > 0) {
137
+ await ctx.scheduler.runAfter(0, internal.appAttestStore.cleanupChallenges, {});
138
+ }
139
+ return expired.length;
140
+ },
141
+ });
@@ -0,0 +1,53 @@
1
+ import { v } from "convex/values";
2
+
3
+ import { internalAction } from "./_generated/server";
4
+
5
+ const REVOKE_URL = "https://appleid.apple.com/auth/revoke";
6
+
7
+ // Revokes a Sign in with Apple refresh token via Apple's REST API. Scheduled
8
+ // from `users.deleteAccount` so app authorization is removed from Apple's
9
+ // side, per Apple's account-deletion requirement:
10
+ //
11
+ // "If people used Sign in with Apple to create an account within your app,
12
+ // you revoke the associated tokens when they delete their account."
13
+ // developer.apple.com/documentation/SigninwithAppleRESTAPI/Revoke-tokens
14
+ //
15
+ // `APPLE_CLIENT_ID` is the SIWA Services ID and `APPLE_CLIENT_SECRET` is the
16
+ // ES256 JWT that the `rotate-apple-jwt` EAS workflow refreshes every 90 days
17
+ // (180-day Apple cap). Best-effort: a failed revoke logs and returns rather
18
+ // than throwing, because the user has already confirmed account deletion and
19
+ // the local rows are about to be wiped anyway.
20
+ export const revokeRefreshToken = internalAction({
21
+ args: { refreshToken: v.string() },
22
+ returns: v.null(),
23
+ handler: async (_ctx, { refreshToken }) => {
24
+ const clientId = process.env.APPLE_CLIENT_ID;
25
+ const clientSecret = process.env.APPLE_CLIENT_SECRET;
26
+ if (!clientId || !clientSecret) {
27
+ console.warn("[apple] revoke skipped: APPLE_CLIENT_ID or APPLE_CLIENT_SECRET unset");
28
+ return null;
29
+ }
30
+
31
+ const body = new URLSearchParams({
32
+ client_id: clientId,
33
+ client_secret: clientSecret,
34
+ token: refreshToken,
35
+ token_type_hint: "refresh_token",
36
+ });
37
+
38
+ try {
39
+ const res = await fetch(REVOKE_URL, {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
42
+ body: body.toString(),
43
+ });
44
+ if (!res.ok) {
45
+ const text = await res.text();
46
+ console.warn(`[apple] revoke failed ${res.status}: ${text}`);
47
+ }
48
+ } catch (err) {
49
+ console.warn(`[apple] revoke threw: ${err instanceof Error ? err.message : String(err)}`);
50
+ }
51
+ return null;
52
+ },
53
+ });