@variantlab/core 0.1.4 → 0.1.6
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.
- package/README.md +1209 -39
- package/docs/API.md +692 -0
- package/docs/ARCHITECTURE.md +430 -0
- package/docs/CONTRIBUTING.md +264 -0
- package/docs/ROADMAP.md +292 -0
- package/docs/SECURITY.md +323 -0
- package/docs/design/api-philosophy.md +347 -0
- package/docs/design/config-format.md +442 -0
- package/docs/design/design-principles.md +212 -0
- package/docs/design/targeting-dsl.md +433 -0
- package/docs/features/codegen.md +351 -0
- package/docs/features/crash-rollback.md +399 -0
- package/docs/features/debug-overlay.md +328 -0
- package/docs/features/hmac-signing.md +330 -0
- package/docs/features/killer-features.md +308 -0
- package/docs/features/multivariate.md +339 -0
- package/docs/features/qr-sharing.md +372 -0
- package/docs/features/targeting.md +481 -0
- package/docs/features/time-travel.md +306 -0
- package/docs/features/value-experiments.md +487 -0
- package/docs/phases/phase-2-expansion.md +307 -0
- package/docs/phases/phase-3-ecosystem.md +289 -0
- package/docs/phases/phase-4-advanced.md +306 -0
- package/docs/phases/phase-5-v1-stable.md +350 -0
- package/docs/research/bundle-size-analysis.md +279 -0
- package/docs/research/competitors.md +327 -0
- package/docs/research/framework-ssr-quirks.md +394 -0
- package/docs/research/naming-rationale.md +238 -0
- package/docs/research/origin-story.md +179 -0
- package/docs/research/security-threats.md +312 -0
- package/package.json +2 -1
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# Debug overlay
|
|
2
|
+
|
|
3
|
+
The on-device debug overlay is variantlab's signature feature. This document describes the design, the UX, and the security constraints.
|
|
4
|
+
|
|
5
|
+
## Table of contents
|
|
6
|
+
|
|
7
|
+
- [What it is](#what-it-is)
|
|
8
|
+
- [UX design](#ux-design)
|
|
9
|
+
- [Production safety](#production-safety)
|
|
10
|
+
- [Activation modes](#activation-modes)
|
|
11
|
+
- [Information architecture](#information-architecture)
|
|
12
|
+
- [Customization](#customization)
|
|
13
|
+
- [Accessibility](#accessibility)
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## What it is
|
|
18
|
+
|
|
19
|
+
`<VariantDebugOverlay>` is a component that renders a floating button plus a bottom-sheet picker. In dev builds, you mount it once at the root:
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import { VariantDebugOverlay } from "@variantlab/react-native";
|
|
23
|
+
|
|
24
|
+
export default function App() {
|
|
25
|
+
return (
|
|
26
|
+
<VariantLabProvider>
|
|
27
|
+
<MainApp />
|
|
28
|
+
{__DEV__ && <VariantDebugOverlay />}
|
|
29
|
+
</VariantLabProvider>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
When the user taps the floating button (or shakes the device, if enabled), a bottom sheet slides up showing every experiment active on the current route.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## UX design
|
|
39
|
+
|
|
40
|
+
### Floating button
|
|
41
|
+
|
|
42
|
+
- 48×48 circle, absolute positioned
|
|
43
|
+
- Configurable corner: `top-left`, `top-right`, `bottom-left`, `bottom-right`
|
|
44
|
+
- Configurable offset from edges
|
|
45
|
+
- Shows a badge with the count of active experiments on the current route
|
|
46
|
+
- Long-press reveals a tooltip: "variantlab debug overlay"
|
|
47
|
+
- Respects safe-area insets
|
|
48
|
+
|
|
49
|
+
### Bottom sheet picker
|
|
50
|
+
|
|
51
|
+
- Slides up from the bottom on tap
|
|
52
|
+
- Dims the background (tap to dismiss)
|
|
53
|
+
- Max height: 85% of screen
|
|
54
|
+
- Contains:
|
|
55
|
+
1. A header bar with title + close button
|
|
56
|
+
2. A search input (filters by experiment name/ID)
|
|
57
|
+
3. A toggle: "Current route only" / "All experiments"
|
|
58
|
+
4. A scrollable list of experiment cards
|
|
59
|
+
5. A footer with "Reset all" and "Share state" buttons
|
|
60
|
+
|
|
61
|
+
### Experiment card
|
|
62
|
+
|
|
63
|
+
Each experiment card shows:
|
|
64
|
+
|
|
65
|
+
- Experiment name
|
|
66
|
+
- Experiment ID (subtle, monospace)
|
|
67
|
+
- Current variant (highlighted)
|
|
68
|
+
- Assignment reason ("by targeting", "by manual override", "by default")
|
|
69
|
+
- Tap to expand: list of all variants with a radio picker
|
|
70
|
+
- Each variant row shows: label, description, "set active" button
|
|
71
|
+
- An overflow menu: "Copy ID", "Reset to default", "Share state"
|
|
72
|
+
|
|
73
|
+
### Search
|
|
74
|
+
|
|
75
|
+
- Case-insensitive, substring match on ID + name + label
|
|
76
|
+
- Debounced 100 ms
|
|
77
|
+
- Highlights matches in the list
|
|
78
|
+
|
|
79
|
+
### Empty state
|
|
80
|
+
|
|
81
|
+
When no experiments match the current route:
|
|
82
|
+
|
|
83
|
+
> No experiments on this route.
|
|
84
|
+
> Toggle "All experiments" above to see the full list.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Production safety
|
|
89
|
+
|
|
90
|
+
The overlay is a dev-only tool. In production, mounting it is a bug. We make it hard to do accidentally.
|
|
91
|
+
|
|
92
|
+
### Auto-disable in production
|
|
93
|
+
|
|
94
|
+
The overlay component checks multiple signals before rendering:
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
function VariantDebugOverlay({ forceEnable = false }) {
|
|
98
|
+
const isDev =
|
|
99
|
+
forceEnable ||
|
|
100
|
+
(typeof __DEV__ !== "undefined" && __DEV__) ||
|
|
101
|
+
process.env.NODE_ENV === "development";
|
|
102
|
+
|
|
103
|
+
if (!isDev) {
|
|
104
|
+
if (process.env.NODE_ENV === "production") {
|
|
105
|
+
console.warn(
|
|
106
|
+
"[variantlab] VariantDebugOverlay rendered in production. " +
|
|
107
|
+
"Use forceEnable={true} if this is intentional.",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return <OverlayImpl />;
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Tree-shaking in production builds
|
|
118
|
+
|
|
119
|
+
The overlay implementation is exported from a separate entry point:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// @variantlab/react-native/debug
|
|
123
|
+
export { VariantDebugOverlay } from "./overlay.js";
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
If the user doesn't import from `/debug`, the overlay code is not bundled. This keeps production bundles clean even if a developer forgets to wrap in `__DEV__`.
|
|
127
|
+
|
|
128
|
+
### Never log user data
|
|
129
|
+
|
|
130
|
+
The overlay uses the same storage as the engine and respects the same privacy rules:
|
|
131
|
+
|
|
132
|
+
- No telemetry
|
|
133
|
+
- No remote logging
|
|
134
|
+
- No external network calls
|
|
135
|
+
- All state is local
|
|
136
|
+
|
|
137
|
+
### Rate-limited actions
|
|
138
|
+
|
|
139
|
+
Rapid-fire variant switching is rate-limited to 5 changes/second to prevent accidental state thrashing or runaway loops.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Activation modes
|
|
144
|
+
|
|
145
|
+
### Tap (default)
|
|
146
|
+
|
|
147
|
+
Tap the floating button to open.
|
|
148
|
+
|
|
149
|
+
### Shake gesture (opt-in)
|
|
150
|
+
|
|
151
|
+
```tsx
|
|
152
|
+
<VariantDebugOverlay
|
|
153
|
+
activation={{ shake: true, shakeThreshold: 15 }}
|
|
154
|
+
/>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Uses device motion to detect a shake. Threshold configurable. On React Native, uses `react-native`'s `DeviceEventEmitter` (Android) or a lightweight motion sensor abstraction. On web, uses `DeviceMotionEvent` (requires user permission on iOS).
|
|
158
|
+
|
|
159
|
+
### Secret gesture
|
|
160
|
+
|
|
161
|
+
Advanced: a 4-corner tap sequence in a specific order. Useful for hiding the overlay until QA knows the gesture.
|
|
162
|
+
|
|
163
|
+
### Keyboard shortcut (web only)
|
|
164
|
+
|
|
165
|
+
`Ctrl+Shift+V` toggles the overlay on web builds.
|
|
166
|
+
|
|
167
|
+
### URL parameter (web only)
|
|
168
|
+
|
|
169
|
+
Append `?__variantlab=1` to the URL to force-enable the overlay even in production (requires `forceEnable` on the component).
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Information architecture
|
|
174
|
+
|
|
175
|
+
### The "Overview" tab (default)
|
|
176
|
+
|
|
177
|
+
Shows experiments matching the current route, grouped by:
|
|
178
|
+
|
|
179
|
+
- **Active** — experiments this user is enrolled in
|
|
180
|
+
- **Targeted but not enrolled** — experiments the user matches but was excluded by weights
|
|
181
|
+
- **Not targeted** — experiments that exist but don't match this user's context
|
|
182
|
+
|
|
183
|
+
### The "History" tab
|
|
184
|
+
|
|
185
|
+
Time-travel inspector. See [`time-travel.md`](./time-travel.md).
|
|
186
|
+
|
|
187
|
+
### The "Context" tab
|
|
188
|
+
|
|
189
|
+
Shows the current `VariantContext`:
|
|
190
|
+
|
|
191
|
+
- Platform, app version, locale
|
|
192
|
+
- Screen size bucket
|
|
193
|
+
- Route
|
|
194
|
+
- User ID (masked)
|
|
195
|
+
- Attributes (JSON tree view)
|
|
196
|
+
|
|
197
|
+
### The "Config" tab
|
|
198
|
+
|
|
199
|
+
Shows the loaded config:
|
|
200
|
+
|
|
201
|
+
- Version
|
|
202
|
+
- Source (bundled / remote / file)
|
|
203
|
+
- Last fetched
|
|
204
|
+
- Signature status (valid / invalid / none)
|
|
205
|
+
- Total experiments count
|
|
206
|
+
|
|
207
|
+
### The "Events" tab
|
|
208
|
+
|
|
209
|
+
Live-tailing telemetry stream (if configured):
|
|
210
|
+
|
|
211
|
+
- Every `assignment`, `exposure`, `variantChanged`, `rollback` event
|
|
212
|
+
- Timestamp, experiment, variant, reason
|
|
213
|
+
- Can be paused and exported as JSON
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Customization
|
|
218
|
+
|
|
219
|
+
Users can customize the overlay extensively:
|
|
220
|
+
|
|
221
|
+
```tsx
|
|
222
|
+
<VariantDebugOverlay
|
|
223
|
+
position="bottom-right"
|
|
224
|
+
offset={{ x: 20, y: 80 }}
|
|
225
|
+
theme="dark"
|
|
226
|
+
activation={{ tap: true, shake: true, shortcut: "mod+shift+v" }}
|
|
227
|
+
tabs={["overview", "history", "context", "config", "events"]}
|
|
228
|
+
showBadge
|
|
229
|
+
locked={false}
|
|
230
|
+
onOpen={() => console.log("overlay opened")}
|
|
231
|
+
onClose={() => console.log("overlay closed")}
|
|
232
|
+
onVariantChange={(exp, variant) => console.log(exp, variant)}
|
|
233
|
+
renderButton={(props) => <CustomButton {...props} />}
|
|
234
|
+
/>
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Custom button render
|
|
238
|
+
|
|
239
|
+
Users who want a non-floating button can render their own trigger:
|
|
240
|
+
|
|
241
|
+
```tsx
|
|
242
|
+
<VariantDebugOverlay renderButton={() => null} ref={overlayRef} />
|
|
243
|
+
|
|
244
|
+
<Button onPress={() => overlayRef.current?.open()}>
|
|
245
|
+
Open debug
|
|
246
|
+
</Button>
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Custom theme
|
|
250
|
+
|
|
251
|
+
The overlay respects the app's dark/light theme by default, but users can override:
|
|
252
|
+
|
|
253
|
+
```tsx
|
|
254
|
+
<VariantDebugOverlay
|
|
255
|
+
theme={{
|
|
256
|
+
background: "#1a1a1a",
|
|
257
|
+
foreground: "#ffffff",
|
|
258
|
+
accent: "#8b5cf6",
|
|
259
|
+
border: "#333",
|
|
260
|
+
}}
|
|
261
|
+
/>
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## Accessibility
|
|
267
|
+
|
|
268
|
+
### Screen readers
|
|
269
|
+
|
|
270
|
+
- The floating button has `accessibilityLabel="Open variantlab debug overlay"`
|
|
271
|
+
- Each experiment card announces: "Experiment {name}, current variant {variant}, double tap to expand"
|
|
272
|
+
- Variant radios use native radio semantics
|
|
273
|
+
- Focus is trapped inside the sheet when open
|
|
274
|
+
- ESC closes the sheet (web)
|
|
275
|
+
|
|
276
|
+
### Keyboard navigation (web)
|
|
277
|
+
|
|
278
|
+
- `Tab` cycles focus through controls
|
|
279
|
+
- `Enter` / `Space` activates buttons
|
|
280
|
+
- Arrow keys move between variant rows
|
|
281
|
+
- `ESC` closes the overlay
|
|
282
|
+
|
|
283
|
+
### Reduced motion
|
|
284
|
+
|
|
285
|
+
If the user has `prefers-reduced-motion`, the slide animation is replaced with a fade.
|
|
286
|
+
|
|
287
|
+
### Contrast
|
|
288
|
+
|
|
289
|
+
All text meets WCAG AA contrast against the overlay background in both light and dark themes.
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## Performance
|
|
294
|
+
|
|
295
|
+
- The overlay lazy-loads its tab implementations
|
|
296
|
+
- The experiment list is virtualized when > 50 experiments
|
|
297
|
+
- The search input is debounced
|
|
298
|
+
- No re-renders on unrelated state changes (isolated subscription)
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## Dependencies
|
|
303
|
+
|
|
304
|
+
- Core: `@variantlab/core`
|
|
305
|
+
- React Native: no extra deps beyond React Native itself
|
|
306
|
+
- Web: no extra deps beyond React
|
|
307
|
+
- No animation library (hand-rolled with Animated API / CSS transitions)
|
|
308
|
+
- No bottom-sheet library (hand-rolled modal)
|
|
309
|
+
- No icon library (inline SVGs)
|
|
310
|
+
|
|
311
|
+
This keeps the overlay < 4 KB gzipped even with all features.
|
|
312
|
+
|
|
313
|
+
---
|
|
314
|
+
|
|
315
|
+
## Integration with other features
|
|
316
|
+
|
|
317
|
+
- **Route scoping** ([`targeting.md`](./targeting.md#routes)): the overlay filters by `useRouteExperiments`
|
|
318
|
+
- **Time travel** ([`time-travel.md`](./time-travel.md)): the history tab
|
|
319
|
+
- **QR sharing** ([`qr-sharing.md`](./qr-sharing.md)): the "Share state" button generates a QR
|
|
320
|
+
- **Crash rollback** ([`crash-rollback.md`](./crash-rollback.md)): rollback events show in the events tab
|
|
321
|
+
- **Codegen** ([`codegen.md`](./codegen.md)): overlay shows variant labels from the generated types
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## See also
|
|
326
|
+
|
|
327
|
+
- [`API.md`](../../API.md) — `VariantDebugOverlay` props
|
|
328
|
+
- [`origin-story.md`](../research/origin-story.md) — why this feature exists
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# HMAC-signed remote configs
|
|
2
|
+
|
|
3
|
+
Tamper-proof remote config delivery via HMAC-SHA256 signatures verified with Web Crypto API.
|
|
4
|
+
|
|
5
|
+
## Table of contents
|
|
6
|
+
|
|
7
|
+
- [Why signing matters](#why-signing-matters)
|
|
8
|
+
- [The threat model](#the-threat-model)
|
|
9
|
+
- [How it works](#how-it-works)
|
|
10
|
+
- [Generating a signature](#generating-a-signature)
|
|
11
|
+
- [Verifying a signature](#verifying-a-signature)
|
|
12
|
+
- [Key management](#key-management)
|
|
13
|
+
- [What signing does NOT protect](#what-signing-does-not-protect)
|
|
14
|
+
- [Deployment workflow](#deployment-workflow)
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Why signing matters
|
|
19
|
+
|
|
20
|
+
When your app fetches `experiments.json` from a remote URL, anyone who can intercept that request can modify the config. That includes:
|
|
21
|
+
|
|
22
|
+
- MITM attackers on public Wi-Fi
|
|
23
|
+
- Compromised CDNs
|
|
24
|
+
- DNS hijacking
|
|
25
|
+
- Rogue employees with deploy access
|
|
26
|
+
- Supply chain attackers in your build pipeline
|
|
27
|
+
|
|
28
|
+
A modified config can:
|
|
29
|
+
|
|
30
|
+
- Enable features meant for staging
|
|
31
|
+
- Change pricing
|
|
32
|
+
- Collect user data (if combined with telemetry)
|
|
33
|
+
- Break the app via invalid variants
|
|
34
|
+
|
|
35
|
+
TLS is **not sufficient**. TLS protects the transport, not the content. If the attacker compromises your CDN or your S3 bucket, they ship the config over TLS just like you do.
|
|
36
|
+
|
|
37
|
+
HMAC signing protects the **content** regardless of how it was transported.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## The threat model
|
|
42
|
+
|
|
43
|
+
variantlab signing defends against:
|
|
44
|
+
|
|
45
|
+
- **T1**: Malicious remote config (CDN compromise, MITM, internal rogue) — mitigated by signing
|
|
46
|
+
- **T2**: Tampered local storage (malicious app, device compromise) — out of scope (TLS + signing both fail)
|
|
47
|
+
- **T8**: Unauthorized variant overrides via deep link — mitigated via the same HMAC mechanism on share payloads
|
|
48
|
+
|
|
49
|
+
See [`SECURITY.md`](../../SECURITY.md) for the full threat table.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## How it works
|
|
54
|
+
|
|
55
|
+
### Signing
|
|
56
|
+
|
|
57
|
+
1. Developer writes `experiments.json`
|
|
58
|
+
2. `variantlab sign experiments.json --key $SECRET` computes HMAC-SHA256 over the canonical JSON form
|
|
59
|
+
3. The CLI writes the signature into the `signature` field of the JSON
|
|
60
|
+
4. The signed file is deployed to the CDN
|
|
61
|
+
|
|
62
|
+
### Verification
|
|
63
|
+
|
|
64
|
+
1. App fetches the signed JSON
|
|
65
|
+
2. Engine extracts the `signature` field and strips it from the JSON
|
|
66
|
+
3. Engine computes HMAC-SHA256 over the canonical form of the remaining JSON
|
|
67
|
+
4. Engine compares the computed signature with the provided signature using `crypto.subtle.verify` (constant time)
|
|
68
|
+
5. If they match, the config is applied. If not, the config is rejected and the engine falls back to the bundled config.
|
|
69
|
+
|
|
70
|
+
### Canonical form
|
|
71
|
+
|
|
72
|
+
HMAC requires a deterministic byte representation of the input. We define the canonical form as:
|
|
73
|
+
|
|
74
|
+
- UTF-8 encoding
|
|
75
|
+
- Keys sorted lexicographically at every level
|
|
76
|
+
- No whitespace
|
|
77
|
+
- `"\u"` escapes normalized
|
|
78
|
+
- Signature field removed before signing/verifying
|
|
79
|
+
|
|
80
|
+
This matches [RFC 8785 (JSON Canonicalization Scheme)](https://www.rfc-editor.org/rfc/rfc8785).
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Generating a signature
|
|
85
|
+
|
|
86
|
+
### CLI
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# Set the key via env var
|
|
90
|
+
export VARIANTLAB_HMAC_KEY=mysecret123
|
|
91
|
+
|
|
92
|
+
# Sign the file in place
|
|
93
|
+
variantlab sign experiments.json
|
|
94
|
+
|
|
95
|
+
# Or pipe
|
|
96
|
+
cat experiments.json | variantlab sign --key $VARIANTLAB_HMAC_KEY > signed.json
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Programmatically
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { signConfig } from "@variantlab/cli";
|
|
103
|
+
|
|
104
|
+
const signed = await signConfig(config, {
|
|
105
|
+
key: process.env.VARIANTLAB_HMAC_KEY,
|
|
106
|
+
});
|
|
107
|
+
// signed.signature = "base64url-encoded-signature"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Output format
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"$schema": "https://variantlab.dev/schemas/experiments.schema.json",
|
|
115
|
+
"version": 1,
|
|
116
|
+
"signature": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
|
117
|
+
"experiments": [...]
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The signature is the base64url-encoded HMAC-SHA256 of the canonical form (with `signature` field excluded).
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Verifying a signature
|
|
126
|
+
|
|
127
|
+
### At engine creation
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import { createEngine } from "@variantlab/core";
|
|
131
|
+
|
|
132
|
+
const engine = createEngine(config, {
|
|
133
|
+
hmacKey: process.env.NEXT_PUBLIC_VARIANTLAB_KEY,
|
|
134
|
+
onSignatureFailure: "reject", // or "warn"
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- `"reject"` — reject the config, fall back to the bundled one, emit a warning
|
|
139
|
+
- `"warn"` — log a warning but still apply the config (useful for soft rollouts of signing)
|
|
140
|
+
|
|
141
|
+
### With remote fetching
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { createHttpFetcher } from "@variantlab/core/fetcher";
|
|
145
|
+
|
|
146
|
+
const fetcher = createHttpFetcher({
|
|
147
|
+
url: "https://cdn.example.com/experiments.json",
|
|
148
|
+
headers: { "Cache-Control": "no-cache" },
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const engine = createEngine(initialConfig, {
|
|
152
|
+
fetcher,
|
|
153
|
+
hmacKey: "mysecret",
|
|
154
|
+
pollInterval: 60000,
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Every fetched config is verified before being applied.
|
|
159
|
+
|
|
160
|
+
### Manual verification
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
import { verifySignature } from "@variantlab/core/crypto";
|
|
164
|
+
|
|
165
|
+
const isValid = await verifySignature(config, key);
|
|
166
|
+
if (!isValid) {
|
|
167
|
+
throw new Error("Config signature invalid");
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Key management
|
|
174
|
+
|
|
175
|
+
The HMAC key is a shared secret between:
|
|
176
|
+
|
|
177
|
+
1. The build system that signs configs
|
|
178
|
+
2. The client app that verifies them
|
|
179
|
+
|
|
180
|
+
### Where to store the key
|
|
181
|
+
|
|
182
|
+
**On the build system**:
|
|
183
|
+
|
|
184
|
+
- Environment variable in CI (`VARIANTLAB_HMAC_KEY`)
|
|
185
|
+
- Secrets manager (AWS Secrets Manager, Vault, GCP Secret Manager)
|
|
186
|
+
- **Never** commit to git
|
|
187
|
+
|
|
188
|
+
**On the client**:
|
|
189
|
+
|
|
190
|
+
- Embedded at build time via env var (`NEXT_PUBLIC_VARIANTLAB_KEY`)
|
|
191
|
+
- The key is visible in the client bundle — treat it as a shared secret, not a private key
|
|
192
|
+
- **This means**: anyone who reverse-engineers your app can extract the key
|
|
193
|
+
|
|
194
|
+
### Why is that OK?
|
|
195
|
+
|
|
196
|
+
HMAC is not authenticating the user; it's authenticating the **config source**. Even if an attacker extracts the key, they still can't:
|
|
197
|
+
|
|
198
|
+
- Push a signed config to your CDN (they'd need deploy access)
|
|
199
|
+
- Forge a config that your CDN serves (it's the CDN's bytes, not theirs)
|
|
200
|
+
|
|
201
|
+
The only attack the extracted key enables is: the attacker signs their own config and somehow gets your app to fetch it (e.g., via a malicious deep link with a custom fetcher). That's a multi-step attack that signing alone can't prevent.
|
|
202
|
+
|
|
203
|
+
### If you need key secrecy
|
|
204
|
+
|
|
205
|
+
If you absolutely need the key to be secret:
|
|
206
|
+
|
|
207
|
+
- Fetch the config from your authenticated API instead of a public CDN
|
|
208
|
+
- Use TLS pinning in the fetcher
|
|
209
|
+
- Sign with a rotating key that's fetched from a secure endpoint
|
|
210
|
+
|
|
211
|
+
This is out of scope for variantlab core. We provide the primitives; you compose the policy.
|
|
212
|
+
|
|
213
|
+
### Key rotation
|
|
214
|
+
|
|
215
|
+
To rotate:
|
|
216
|
+
|
|
217
|
+
1. Generate a new key
|
|
218
|
+
2. Update the build system to sign with the new key
|
|
219
|
+
3. Ship an app update with the new key
|
|
220
|
+
4. Wait for the old app version to age out
|
|
221
|
+
5. Retire the old key
|
|
222
|
+
|
|
223
|
+
The engine supports **multiple keys** for smooth rotation:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
createEngine(config, {
|
|
227
|
+
hmacKeys: [
|
|
228
|
+
{ id: "v2", key: currentKey },
|
|
229
|
+
{ id: "v1", key: previousKey },
|
|
230
|
+
],
|
|
231
|
+
});
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The config's `signature` field can optionally include a key ID prefix: `"v2:dBjftJeZ..."`. The engine looks up the matching key.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## What signing does NOT protect
|
|
239
|
+
|
|
240
|
+
### Replay attacks
|
|
241
|
+
|
|
242
|
+
An old signed config is still valid. An attacker who captures `experiments.v1.json` can replay it later even after you've shipped `experiments.v2.json`.
|
|
243
|
+
|
|
244
|
+
**Mitigation**: include a `nonce` or `timestamp` field in the config and reject old ones in the engine options. Post-v0.1.
|
|
245
|
+
|
|
246
|
+
### Rollback to an older version
|
|
247
|
+
|
|
248
|
+
Related to replay. If the attacker serves an older signed config with a lower `version`, the engine may accept it.
|
|
249
|
+
|
|
250
|
+
**Mitigation**: the engine tracks the highest version it has seen and rejects configs with lower versions.
|
|
251
|
+
|
|
252
|
+
### Key compromise
|
|
253
|
+
|
|
254
|
+
If the HMAC key leaks, the attacker can sign arbitrary configs. Rotate the key immediately.
|
|
255
|
+
|
|
256
|
+
### Bundled config tampering
|
|
257
|
+
|
|
258
|
+
The bundled `experiments.json` (shipped inside the app binary) is not signed at runtime — if someone tampers with the app bundle, they can replace it. This is out of scope — if an attacker can modify your app binary, they can do far worse than change experiments.
|
|
259
|
+
|
|
260
|
+
### DoS via malformed signature
|
|
261
|
+
|
|
262
|
+
A config with an invalid signature is rejected, and the engine falls back. This is the correct behavior — it's not a vulnerability. But it does mean an attacker who can serve configs can deny new ones.
|
|
263
|
+
|
|
264
|
+
**Mitigation**: pin the last-known-good config in Storage and apply it if the fetched one fails verification.
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Deployment workflow
|
|
269
|
+
|
|
270
|
+
The canonical flow for signed remote configs:
|
|
271
|
+
|
|
272
|
+
```
|
|
273
|
+
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
|
274
|
+
│ Developer │ │ CI │ │ CDN │ │ User device │
|
|
275
|
+
│ edits json │──▶│ signs json │──▶│ serves json │──▶│ verifies │
|
|
276
|
+
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Example GitHub Actions workflow
|
|
280
|
+
|
|
281
|
+
```yaml
|
|
282
|
+
name: Deploy experiments
|
|
283
|
+
on:
|
|
284
|
+
push:
|
|
285
|
+
paths: ['experiments.json']
|
|
286
|
+
|
|
287
|
+
jobs:
|
|
288
|
+
deploy:
|
|
289
|
+
runs-on: ubuntu-latest
|
|
290
|
+
steps:
|
|
291
|
+
- uses: actions/checkout@v4
|
|
292
|
+
|
|
293
|
+
- name: Install variantlab CLI
|
|
294
|
+
run: npm install -g @variantlab/cli
|
|
295
|
+
|
|
296
|
+
- name: Validate config
|
|
297
|
+
run: variantlab validate experiments.json
|
|
298
|
+
|
|
299
|
+
- name: Sign config
|
|
300
|
+
run: variantlab sign experiments.json --out signed.json
|
|
301
|
+
env:
|
|
302
|
+
VARIANTLAB_HMAC_KEY: ${{ secrets.VARIANTLAB_HMAC_KEY }}
|
|
303
|
+
|
|
304
|
+
- name: Upload to S3
|
|
305
|
+
run: aws s3 cp signed.json s3://config.example.com/experiments.json
|
|
306
|
+
--cache-control "max-age=60"
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### CDN cache control
|
|
310
|
+
|
|
311
|
+
Signed configs should have a short cache TTL (e.g., 60s) so that config updates propagate quickly. The engine's `pollInterval` should match or be slightly longer.
|
|
312
|
+
|
|
313
|
+
---
|
|
314
|
+
|
|
315
|
+
## Cost of signing
|
|
316
|
+
|
|
317
|
+
- **Bundle size**: ~300 bytes (Web Crypto API has no bundle cost; we only ship the canonicalizer and the verify wrapper)
|
|
318
|
+
- **Runtime**: HMAC verification takes ~1 ms on a modern device
|
|
319
|
+
- **Build time**: Signing takes < 100 ms
|
|
320
|
+
|
|
321
|
+
Signing is cheap. There's no reason not to turn it on for production remote configs.
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## See also
|
|
326
|
+
|
|
327
|
+
- [`SECURITY.md`](../../SECURITY.md) — threat model
|
|
328
|
+
- [`config-format.md`](../design/config-format.md) — the `signature` field
|
|
329
|
+
- [`API.md`](../../API.md) — `signConfig`, `verifySignature`
|
|
330
|
+
- [RFC 8785 — JSON Canonicalization Scheme](https://www.rfc-editor.org/rfc/rfc8785)
|