health-export-mcp 1.4.4 → 1.6.0
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 +52 -4
- package/envelope.mjs +239 -0
- package/glama.json +1 -1
- package/health-export.mcpb +0 -0
- package/healthstore.mjs +519 -27
- package/llms.txt +1 -1
- package/manifest.json +20 -4
- package/metric-catalog.mjs +204 -0
- package/normalize.mjs +374 -0
- package/package.json +4 -1
- package/receiver.mjs +102 -21
- package/server.json +2 -2
- package/server.mjs +51 -27
- package/test/integration.mjs +12 -3
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
</p>
|
|
17
17
|
|
|
18
18
|
<p align="center">
|
|
19
|
-
<img src="assets/architecture.svg" alt="Apple Health exports to iCloud, a folder, or your LAN; health-export-mcp reads it and serves
|
|
19
|
+
<img src="assets/architecture.svg" alt="Apple Health exports to iCloud, a folder, or your LAN; health-export-mcp reads it and serves 16 query tools to your AI agent" width="100%" />
|
|
20
20
|
</p>
|
|
21
21
|
|
|
22
22
|
> Ask your agent: *"Compare my HRV this week vs last week and tell me if I'm recovering."*, it calls the tools and answers from your **actual numbers**.
|
|
@@ -109,13 +109,14 @@ Restart the client and try:
|
|
|
109
109
|
|
|
110
110
|
---
|
|
111
111
|
|
|
112
|
-
## The
|
|
112
|
+
## The 16 MCP tools
|
|
113
113
|
|
|
114
114
|
Full reference with request/response examples: **[healthexport.dev/mcp](https://www.healthexport.dev/mcp/#tools)**.
|
|
115
115
|
|
|
116
116
|
| Tool | What it does |
|
|
117
117
|
|---|---|
|
|
118
|
-
| `get_mcp_status` | Health check: source, metric/workout counts, which context files exist, latest data date. **Call first.** |
|
|
118
|
+
| `get_mcp_status` | Health check: source, metric/workout counts, which context files exist, latest data date, and a `freshness` summary. **Call first.** |
|
|
119
|
+
| `get_freshness` | How current the export is: `stale` against a stated threshold (26 h by default, `HEALTH_STALE_AFTER_HOURS`, or `maxAgeHours`), `age_hours`, `as_of`, `last_data_date`, per-file write times. Same rule as the `status --max-age` cron gate. |
|
|
119
120
|
| `list_metrics` | Every available metric with unit, day count, and date range. |
|
|
120
121
|
| `get_health_metrics` | Daily values for a metric (or all) over a date range + an aggregate (avg/sum/min/max/latest). Supports `filterDays` (restrict to days covered by logged events, with `negate`). |
|
|
121
122
|
| `get_trends` | Recent N-day window vs the prior N days: change, % change, direction. Supports `excludeTravelDays`. |
|
|
@@ -129,6 +130,7 @@ Full reference with request/response examples: **[healthexport.dev/mcp](https://
|
|
|
129
130
|
| `get_sleep_sessions` | Clustered sleep sessions attributed to the waking day; split nights returned as-is. |
|
|
130
131
|
| `get_cycle_context` | Day-in-cycle and coarse phase derived from logged period starts. Never predictive. |
|
|
131
132
|
| `correlate_metrics` | Pearson correlation between two metrics with a 0..3 day lag. Association, not causation, always stated. |
|
|
133
|
+
| `resolve_metric` | Canonical name and unit for any spelling (`steps`, `StepCount`, `Body Weight`, `hrv`), unit variants with exact formulas, an optional `{value, unit}` conversion, and whether the export holds it. Ambiguous words come back with candidates, never guessed. The data tools accept the same aliases and report `resolvedFrom`. |
|
|
132
134
|
|
|
133
135
|
**Coverage:** 190 Apple Health metrics across activity, heart, HRV, mobility, respiratory, body, sleep, hearing, and nutrition, plus workouts. Data answers carry honest `coverage` blocks, and single-metric answers list logged events inside the window as `segmentBoundaries` so an average across a medication start or life change cannot masquerade as one regime.
|
|
134
136
|
|
|
@@ -200,11 +202,57 @@ node server.mjs --help
|
|
|
200
202
|
- **Read-only.** The server only reads your exported data, it never touches HealthKit and never writes back.
|
|
201
203
|
- **Local-first.** It runs on your machine over stdio. There is **no developer server** in the path.
|
|
202
204
|
- **Optional pairing.** Set `PAIRING_SECRET` to the code the iOS app shows (Settings → Agent pairing) to gate access.
|
|
205
|
+
- **Encrypted exports (optional).** If you switch on Export encryption in the iOS app, set `HEALTH_EXPORT_PASSPHRASE` (or `HEALTH_EXPORT_PASSPHRASE_FILE`) to the same passphrase and the server decrypts on your machine. See [Encrypted exports](#encrypted-exports).
|
|
203
206
|
- **Auditable.** Zero dependencies and a few hundred lines of readable JavaScript, read every line.
|
|
204
207
|
- **Signed releases.** Hosted artifacts are minisign-signed and checksummed, see [Verifying releases](#verifying-releases).
|
|
205
208
|
|
|
206
209
|
---
|
|
207
210
|
|
|
211
|
+
## Encrypted exports
|
|
212
|
+
|
|
213
|
+
The iOS app can encrypt what it exports with a passphrase you choose (Settings > Export encryption,
|
|
214
|
+
switched on per destination: iCloud Drive, a folder, your server, the local network). Give the server
|
|
215
|
+
the same passphrase and every file is decrypted transparently, in memory, on this machine:
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
HEALTH_EXPORT_PASSPHRASE='your passphrase' HEALTH_DATA_DIR=... node server.mjs
|
|
219
|
+
# or keep it out of your shell history and process list:
|
|
220
|
+
HEALTH_EXPORT_PASSPHRASE_FILE=~/.config/metricbridge/passphrase HEALTH_DATA_DIR=... node server.mjs
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
If both are set, `HEALTH_EXPORT_PASSPHRASE` wins; one trailing newline in the file is ignored and an
|
|
224
|
+
empty value counts as unset. The `.mcpb` bundle has an optional **Export passphrase** field. Plaintext
|
|
225
|
+
exports keep working either way. A missing or wrong passphrase, or a file that fails its integrity
|
|
226
|
+
check, is reported as such (`get_mcp_status` says `encrypted: true` plus the reason, `--doctor` prints
|
|
227
|
+
an `encryption:` line), never as "no data". The passphrase is never logged or echoed.
|
|
228
|
+
|
|
229
|
+
The LAN receiver (`HEALTH_LISTEN=1`) decrypts encrypted pushes with the same variable and answers
|
|
230
|
+
`422` with a machine `reason` (`passphrase_missing`, `passphrase_mismatch`, `tampered`, `malformed`,
|
|
231
|
+
`unsupported_version`, `passphrase_file_unreadable`, `encryption_required`). The pairing token rides
|
|
232
|
+
in a plain `http` header on that leg, so set `HEALTH_REQUIRE_ENCRYPTED=1` to refuse plaintext pushes
|
|
233
|
+
once the app encrypts that leg. To decrypt one body yourself (a webhook you run, a script):
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
HEALTH_EXPORT_PASSPHRASE='...' node envelope.mjs open body.json # prints the decrypted JSON
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
**Format** (`metricbridge.enc` v1, `envelope.mjs`, `node:crypto` only): a JSON object
|
|
240
|
+
`{format, v, alg: "A256GCM", kdf: "PBKDF2-HMAC-SHA256", iter, salt, kcv, nonce, ct}`.
|
|
241
|
+
`master = PBKDF2-HMAC-SHA256(NFC(passphrase), salt[16], iter)` (600,000 iterations, the OWASP
|
|
242
|
+
figure); an AES-256-GCM key and an 8-byte key-check value come off it with HKDF-SHA256; each message
|
|
243
|
+
gets a random 12-byte nonce; every header field is bound in as AAD as the string
|
|
244
|
+
`metricbridge.enc|v=1|alg=A256GCM|kdf=PBKDF2-HMAC-SHA256|iter=<n>|salt=<b64>|kcv=<b64>|nonce=<b64>`.
|
|
245
|
+
Base64 is canonical with padding. `test/fixtures/` holds envelopes made by the iOS app's Swift encoder
|
|
246
|
+
and by this module; `envelope.test.mjs` proves both open and regenerate byte for byte.
|
|
247
|
+
|
|
248
|
+
**What it protects:** the export at rest in the destination (iCloud Drive, a synced folder, a
|
|
249
|
+
webhook host's storage) and the LAN push on the wire, plus integrity of every sealed file. **What it
|
|
250
|
+
does not:** an unlocked phone, this computer (it holds the passphrase), metadata (sizes, timing),
|
|
251
|
+
rollback or replay of a whole sealed file, files you share by hand from the app, or a weak passphrase
|
|
252
|
+
(anyone with the files can guess offline; the app requires at least 8 characters).
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
208
256
|
## Verifying releases
|
|
209
257
|
|
|
210
258
|
The server artifacts hosted at `healthexport.dev/mcp/` (used by the [setup skill](https://healthexport.dev/SKILL.md)) are **minisign-signed**, and every download is **SHA-256 checksummed**. The signing public key is published **in this repo** (`minisign.pub`) and in `SKILL.md`, **pin it from here, not only from the website**, so a compromise of the website alone cannot swap both an artifact and its key.
|
|
@@ -270,7 +318,7 @@ stdio transport (newline-delimited JSON-RPC 2.0), the universal MCP transport. O
|
|
|
270
318
|
|
|
271
319
|
## How it works
|
|
272
320
|
|
|
273
|
-
The iOS app reads Apple Health (read-only) and writes a compact `.health-cache.json` (plus optional context files) to the destination you choose. This server reads those files and exposes the
|
|
321
|
+
The iOS app reads Apple Health (read-only) and writes a compact `.health-cache.json` (plus optional context files) to the destination you choose. This server reads those files and exposes the 16 tools above over MCP. No bridge, no Docker, no database: just files and stdio.
|
|
274
322
|
|
|
275
323
|
```
|
|
276
324
|
Apple Health → MetricBridge (iOS) → .health-cache.json → health-export-mcp → MCP client → you
|
package/envelope.mjs
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// envelope.mjs: reads (and, for tests and tooling, writes) the MetricBridge encrypted export
|
|
2
|
+
// envelope, format "metricbridge.enc" version 1. Zero dependencies: node:crypto only.
|
|
3
|
+
//
|
|
4
|
+
// The iOS app can seal what it exports (iCloud Drive / folder files, webhook and local-network
|
|
5
|
+
// bodies) under a passphrase the user chose. This module is the matching decrypt step, so the MCP
|
|
6
|
+
// server and the LAN receiver read encrypted exports transparently once they know the passphrase.
|
|
7
|
+
//
|
|
8
|
+
// Envelope (UTF-8 JSON, keys sorted):
|
|
9
|
+
// { "alg":"A256GCM", "ct":"<b64 ciphertext||16-byte tag>", "format":"metricbridge.enc",
|
|
10
|
+
// "iter":600000, "kcv":"<b64 8 bytes>", "kdf":"PBKDF2-HMAC-SHA256",
|
|
11
|
+
// "nonce":"<b64 12 bytes>", "salt":"<b64 16 bytes>", "v":1 }
|
|
12
|
+
//
|
|
13
|
+
// master = PBKDF2-HMAC-SHA256(NFC(passphrase), salt, iter, 32)
|
|
14
|
+
// encKey = HKDF-SHA256(master, salt = empty, info = "metricbridge.enc/v1/aes-256-gcm", 32)
|
|
15
|
+
// kcv = HKDF-SHA256(master, salt = empty, info = "metricbridge.enc/v1/key-check", 8)
|
|
16
|
+
// AAD = "metricbridge.enc|v=1|alg=A256GCM|kdf=PBKDF2-HMAC-SHA256|iter=<n>|salt=<b64>|kcv=<b64>|nonce=<b64>"
|
|
17
|
+
// (base64 re-encoded from the decoded bytes, canonical with padding)
|
|
18
|
+
//
|
|
19
|
+
// Full specification and threat model: docs/ENCRYPTED-EXPORTS.md in the app repo, and the
|
|
20
|
+
// "Encrypted exports" section of README.md.
|
|
21
|
+
//
|
|
22
|
+
// Passphrase source, in order: HEALTH_EXPORT_PASSPHRASE, then the file named by
|
|
23
|
+
// HEALTH_EXPORT_PASSPHRASE_FILE (one trailing newline is stripped). The passphrase is never logged,
|
|
24
|
+
// never echoed in an error and never written anywhere.
|
|
25
|
+
//
|
|
26
|
+
// CLI (for webhook receivers and scripts that want the plaintext of one envelope):
|
|
27
|
+
// HEALTH_EXPORT_PASSPHRASE=... node envelope.mjs open <file|-> # prints the decrypted JSON
|
|
28
|
+
|
|
29
|
+
import crypto from 'node:crypto';
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
|
|
33
|
+
export const FORMAT = 'metricbridge.enc';
|
|
34
|
+
export const VERSION = 1;
|
|
35
|
+
export const ALG = 'A256GCM';
|
|
36
|
+
export const KDF = 'PBKDF2-HMAC-SHA256';
|
|
37
|
+
export const DEFAULT_ITERATIONS = 600_000; // OWASP Password Storage Cheat Sheet, PBKDF2-HMAC-SHA256
|
|
38
|
+
export const MIN_ITERATIONS = 100_000; // a forged header cannot steer us into a cheap derivation
|
|
39
|
+
export const MAX_ITERATIONS = 10_000_000; // ...or pin the CPU for minutes
|
|
40
|
+
const SALT_LEN = 16, NONCE_LEN = 12, KCV_LEN = 8, TAG_LEN = 16, KEY_LEN = 32;
|
|
41
|
+
const ENC_INFO = Buffer.from('metricbridge.enc/v1/aes-256-gcm', 'utf8');
|
|
42
|
+
const KCV_INFO = Buffer.from('metricbridge.enc/v1/key-check', 'utf8');
|
|
43
|
+
|
|
44
|
+
/** Machine-readable failure. `code` is stable; `message` never contains the passphrase. */
|
|
45
|
+
export class EnvelopeError extends Error {
|
|
46
|
+
constructor(code, message) { super(message); this.name = 'EnvelopeError'; this.code = code; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const isEnvelope = (obj) =>
|
|
50
|
+
!!obj && typeof obj === 'object' && !Array.isArray(obj) && obj.format === FORMAT;
|
|
51
|
+
|
|
52
|
+
/** The configured passphrase, or null. Read on every call so tests (and a restarted config) see
|
|
53
|
+
* the current environment; it is cheap. Empty means unset: the .mcpb manifest maps an empty
|
|
54
|
+
* optional field to an empty string. */
|
|
55
|
+
export function configuredPassphrase(env = process.env) {
|
|
56
|
+
const direct = env.HEALTH_EXPORT_PASSPHRASE;
|
|
57
|
+
if (typeof direct === 'string' && direct.length > 0) return direct;
|
|
58
|
+
const file = env.HEALTH_EXPORT_PASSPHRASE_FILE;
|
|
59
|
+
if (typeof file === 'string' && file.length > 0) {
|
|
60
|
+
let text;
|
|
61
|
+
try { text = fs.readFileSync(file, 'utf8'); }
|
|
62
|
+
catch (e) {
|
|
63
|
+
throw new EnvelopeError('passphrase_file_unreadable',
|
|
64
|
+
`HEALTH_EXPORT_PASSPHRASE_FILE points at a file that could not be read (${e.code || 'error'}).`);
|
|
65
|
+
}
|
|
66
|
+
const p = text.replace(/\r?\n$/, '');
|
|
67
|
+
return p.length > 0 ? p : null;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// PBKDF2 at 600,000 rounds is ~0.3 s; every file the app writes shares one salt, so derive once per
|
|
73
|
+
// (passphrase, salt, iter). Keyed by an HMAC of the passphrase under a per-process random key, so the
|
|
74
|
+
// map holds neither a second copy of the passphrase nor a fast unsalted hash of it.
|
|
75
|
+
// Bounded (LRU): the app uses one salt per passphrase, so a handful of entries is plenty, and an
|
|
76
|
+
// unbounded map would let a stream of fresh-salt envelopes grow memory without limit.
|
|
77
|
+
const keyCache = new Map();
|
|
78
|
+
const KEY_CACHE_MAX = 16;
|
|
79
|
+
const CACHE_ID_KEY = crypto.randomBytes(32);
|
|
80
|
+
|
|
81
|
+
function b64(buf) { return Buffer.from(buf).toString('base64'); }
|
|
82
|
+
|
|
83
|
+
function decodeField(env, name, len) {
|
|
84
|
+
const s = env[name];
|
|
85
|
+
// Canonical padded base64 only: Buffer.from is lenient (drops padding, ignores stray characters)
|
|
86
|
+
// and the Swift reader is not, so both apply the same rule and accept exactly the same envelopes.
|
|
87
|
+
if (typeof s !== 'string' || s.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(s)) {
|
|
88
|
+
throw new EnvelopeError('malformed', `encrypted export is malformed (field "${name}")`);
|
|
89
|
+
}
|
|
90
|
+
const buf = Buffer.from(s, 'base64');
|
|
91
|
+
if (buf.toString('base64') !== s) {
|
|
92
|
+
throw new EnvelopeError('malformed', `encrypted export is malformed (field "${name}")`);
|
|
93
|
+
}
|
|
94
|
+
if (len !== undefined && buf.length !== len) {
|
|
95
|
+
throw new EnvelopeError('malformed', `encrypted export is malformed (field "${name}" length)`);
|
|
96
|
+
}
|
|
97
|
+
return buf;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function exactInt(v) { return Number.isInteger(v) ? v : null; }
|
|
101
|
+
|
|
102
|
+
/** Parse and validate the header of an envelope object. */
|
|
103
|
+
export function parseHeader(env) {
|
|
104
|
+
if (!isEnvelope(env)) throw new EnvelopeError('malformed', 'not an encrypted export');
|
|
105
|
+
const v = exactInt(env.v);
|
|
106
|
+
if (v === null) throw new EnvelopeError('malformed', 'encrypted export is malformed (field "v")');
|
|
107
|
+
if (v !== VERSION) {
|
|
108
|
+
throw new EnvelopeError('unsupported_version',
|
|
109
|
+
`This export was encrypted with envelope version ${v}, but this server only reads version ${VERSION}. ` +
|
|
110
|
+
'Update the MetricBridge MCP server.');
|
|
111
|
+
}
|
|
112
|
+
if (env.alg !== ALG) throw new EnvelopeError('malformed', 'encrypted export is malformed (field "alg")');
|
|
113
|
+
if (env.kdf !== KDF) throw new EnvelopeError('malformed', 'encrypted export is malformed (field "kdf")');
|
|
114
|
+
const iter = exactInt(env.iter);
|
|
115
|
+
if (iter === null || iter < MIN_ITERATIONS || iter > MAX_ITERATIONS) {
|
|
116
|
+
throw new EnvelopeError('malformed', 'encrypted export is malformed (field "iter")');
|
|
117
|
+
}
|
|
118
|
+
const salt = decodeField(env, 'salt', SALT_LEN);
|
|
119
|
+
const kcv = decodeField(env, 'kcv', KCV_LEN);
|
|
120
|
+
const nonce = decodeField(env, 'nonce', NONCE_LEN);
|
|
121
|
+
const sealed = decodeField(env, 'ct');
|
|
122
|
+
if (sealed.length < TAG_LEN) throw new EnvelopeError('malformed', 'encrypted export is malformed (field "ct")');
|
|
123
|
+
return { v, iter, salt, kcv, nonce, sealed };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function associatedData({ v, iter, salt, kcv, nonce }) {
|
|
127
|
+
return Buffer.from(
|
|
128
|
+
`${FORMAT}|v=${v}|alg=${ALG}|kdf=${KDF}|iter=${iter}|salt=${b64(salt)}|kcv=${b64(kcv)}|nonce=${b64(nonce)}`,
|
|
129
|
+
'utf8');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Derive { encKey, kcv } for a passphrase + salt + iteration count (memoized). */
|
|
133
|
+
export function deriveKeys(passphrase, salt, iter) {
|
|
134
|
+
if (typeof passphrase !== 'string' || passphrase.length === 0) {
|
|
135
|
+
throw new EnvelopeError('passphrase_missing', 'no passphrase');
|
|
136
|
+
}
|
|
137
|
+
const pw = Buffer.from(passphrase.normalize('NFC'), 'utf8');
|
|
138
|
+
const id = crypto.createHmac('sha256', CACHE_ID_KEY).update(pw).digest('hex') + '|' + b64(salt) + '|' + iter;
|
|
139
|
+
const hit = keyCache.get(id);
|
|
140
|
+
if (hit) { keyCache.delete(id); keyCache.set(id, hit); return hit; }
|
|
141
|
+
const master = crypto.pbkdf2Sync(pw, salt, iter, KEY_LEN, 'sha256');
|
|
142
|
+
const keys = {
|
|
143
|
+
encKey: Buffer.from(crypto.hkdfSync('sha256', master, Buffer.alloc(0), ENC_INFO, KEY_LEN)),
|
|
144
|
+
kcv: Buffer.from(crypto.hkdfSync('sha256', master, Buffer.alloc(0), KCV_INFO, KCV_LEN)),
|
|
145
|
+
};
|
|
146
|
+
master.fill(0);
|
|
147
|
+
pw.fill(0);
|
|
148
|
+
keyCache.set(id, keys);
|
|
149
|
+
while (keyCache.size > KEY_CACHE_MAX) keyCache.delete(keyCache.keys().next().value);
|
|
150
|
+
return keys;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Decrypt an envelope object to a Buffer of plaintext. Throws EnvelopeError with code
|
|
154
|
+
* 'passphrase_missing' | 'passphrase_mismatch' | 'tampered' | 'malformed' | 'unsupported_version'. */
|
|
155
|
+
export function openEnvelope(env, passphrase = configuredPassphrase()) {
|
|
156
|
+
const h = parseHeader(env);
|
|
157
|
+
if (!passphrase) {
|
|
158
|
+
throw new EnvelopeError('passphrase_missing',
|
|
159
|
+
'This health export is encrypted. Set HEALTH_EXPORT_PASSPHRASE (or HEALTH_EXPORT_PASSPHRASE_FILE) ' +
|
|
160
|
+
'to the passphrase you chose in the MetricBridge app (Settings > Export encryption), then restart the server.');
|
|
161
|
+
}
|
|
162
|
+
const { encKey, kcv } = deriveKeys(passphrase, h.salt, h.iter);
|
|
163
|
+
if (!crypto.timingSafeEqual(kcv, h.kcv)) {
|
|
164
|
+
throw new EnvelopeError('passphrase_mismatch',
|
|
165
|
+
'This health export is encrypted with a different passphrase than HEALTH_EXPORT_PASSPHRASE. ' +
|
|
166
|
+
'Use the passphrase set in the MetricBridge app (Settings > Export encryption).');
|
|
167
|
+
}
|
|
168
|
+
const ct = h.sealed.subarray(0, h.sealed.length - TAG_LEN);
|
|
169
|
+
const tag = h.sealed.subarray(h.sealed.length - TAG_LEN);
|
|
170
|
+
try {
|
|
171
|
+
const d = crypto.createDecipheriv('aes-256-gcm', encKey, h.nonce, { authTagLength: TAG_LEN });
|
|
172
|
+
d.setAAD(associatedData(h));
|
|
173
|
+
d.setAuthTag(tag);
|
|
174
|
+
return Buffer.concat([d.update(ct), d.final()]);
|
|
175
|
+
} catch {
|
|
176
|
+
throw new EnvelopeError('tampered',
|
|
177
|
+
'An encrypted health export failed its integrity check: the file is damaged or was modified. ' +
|
|
178
|
+
'Run an export from the MetricBridge app to rewrite it.');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Decrypt and JSON.parse. */
|
|
183
|
+
export function openEnvelopeJSON(env, passphrase) {
|
|
184
|
+
return JSON.parse(openEnvelope(env, passphrase).toString('utf8'));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** If `obj` is an envelope, return its decrypted JSON; otherwise return it unchanged. */
|
|
188
|
+
export function maybeOpen(obj, passphrase) {
|
|
189
|
+
return isEnvelope(obj) ? openEnvelopeJSON(obj, passphrase === undefined ? configuredPassphrase() : passphrase) : obj;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Seal plaintext (Buffer or string) into an envelope object. Used by tests, by the cross-language
|
|
193
|
+
* fixture and by the receiver when it rewrites a cache that was already sealed. `nonce` and `salt`
|
|
194
|
+
* are injectable for deterministic fixtures ONLY: production use must leave both to the CSPRNG. */
|
|
195
|
+
export function sealEnvelope(plaintext, passphrase, { salt, iter = DEFAULT_ITERATIONS, nonce } = {}) {
|
|
196
|
+
const s = salt ? Buffer.from(salt) : crypto.randomBytes(SALT_LEN);
|
|
197
|
+
const n = nonce ? Buffer.from(nonce) : crypto.randomBytes(NONCE_LEN);
|
|
198
|
+
if (s.length !== SALT_LEN || n.length !== NONCE_LEN) throw new EnvelopeError('malformed', 'bad salt or nonce length');
|
|
199
|
+
const { encKey, kcv } = deriveKeys(passphrase, s, iter);
|
|
200
|
+
const header = { v: VERSION, iter, salt: s, kcv, nonce: n };
|
|
201
|
+
const c = crypto.createCipheriv('aes-256-gcm', encKey, n, { authTagLength: TAG_LEN });
|
|
202
|
+
c.setAAD(associatedData(header));
|
|
203
|
+
const ct = Buffer.concat([c.update(Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(String(plaintext), 'utf8')), c.final()]);
|
|
204
|
+
// Same key order the app writes (sorted), so a fixture regenerates byte-for-byte.
|
|
205
|
+
return {
|
|
206
|
+
alg: ALG,
|
|
207
|
+
ct: b64(Buffer.concat([ct, c.getAuthTag()])),
|
|
208
|
+
format: FORMAT,
|
|
209
|
+
iter,
|
|
210
|
+
kcv: b64(kcv),
|
|
211
|
+
kdf: KDF,
|
|
212
|
+
nonce: b64(n),
|
|
213
|
+
salt: b64(s),
|
|
214
|
+
v: VERSION,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---- CLI ----
|
|
219
|
+
const isEntryPoint = (() => {
|
|
220
|
+
if (!process.argv[1]) return false;
|
|
221
|
+
try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
|
|
222
|
+
catch { return false; }
|
|
223
|
+
})();
|
|
224
|
+
if (isEntryPoint) {
|
|
225
|
+
const [cmd, file] = process.argv.slice(2);
|
|
226
|
+
if (cmd !== 'open' || !file) {
|
|
227
|
+
process.stderr.write('usage: HEALTH_EXPORT_PASSPHRASE=... node envelope.mjs open <file|->\n');
|
|
228
|
+
process.exit(2);
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
const text = fs.readFileSync(file === '-' ? 0 : file, 'utf8');
|
|
232
|
+
const obj = JSON.parse(text);
|
|
233
|
+
if (!isEnvelope(obj)) { process.stdout.write(text); process.exit(0); }
|
|
234
|
+
process.stdout.write(openEnvelope(obj).toString('utf8'));
|
|
235
|
+
} catch (e) {
|
|
236
|
+
process.stderr.write(`envelope: ${e instanceof EnvelopeError ? e.message : 'could not read input (' + (e.code || e.name) + ')'}\n`);
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
}
|
package/glama.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
],
|
|
6
6
|
"name": "health-export-mcp",
|
|
7
7
|
"description": "Query 190 Apple Health/HealthKit metrics from any MCP AI agent: zero-dependency, local-first, read-only. The open-source server for the MetricBridge app.",
|
|
8
|
-
"longDescription": "An open-source, zero-dependency MCP server that turns your Apple Health export into
|
|
8
|
+
"longDescription": "An open-source, zero-dependency MCP server that turns your Apple Health export into 16 query tools any AI agent can call in natural language. Tracks HRV, sleep, resting heart rate, steps, workouts, VO₂ max, and 180+ more metrics, all local-first with no developer server in the path.",
|
|
9
9
|
"categories": [
|
|
10
10
|
"healthcare",
|
|
11
11
|
"lifestyle",
|
package/health-export.mcpb
CHANGED
|
Binary file
|