MetaMask, but for health data
A crypto wallet holds keys, not coins. The coins stay on a public ledger. MyHelix holds the keys to your health records. The records are stored as ciphertext that Binary Helix cannot read. Anything that wants the data has to ask the wallet, and the wallet asks you.
The core is a single TypeScript package (packages/core) built only on the Web Crypto API. It covers the vault, key hierarchy, record envelopes, consent grants, Merkle anchoring and the provider RPC. Three surfaces wrap the same core:
Browser extension
Manifest V3. Injects window.myhelix into clinic and lab portals and opens approval windows.
Web app
Installable PWA. Records, biomarkers, DNA, insights, sharing and a ledger explorer.
Web app demoMobile app
React Native and Expo. Keys in the Secure Enclave or StrongBox, HealthKit and Health Connect import, QR pairing.
Mobile layoutDesign rules
- Encrypt on the device, before storage. Every record has its own key. Servers see only ciphertext.
- No custom crypto. AES-GCM-256, PBKDF2, HKDF, ECDSA and ECDH on P-256, SHA-256. All from Web Crypto or the platform keystore.
- Approval for every access. Connecting shows only your DID and public keys. Reading data takes a separate grant with a scope, a purpose and an expiry.
- Only hashes on chain. Merkle roots of ciphertext hashes and consent-receipt hashes. No health data, names or identifiers.
- Users never hold crypto. Smart accounts use passkey-compatible P-256 signatures, and a paymaster covers gas. No token, and no NFTs of health data.
About the demo. app.html runs this design in your browser with fictional data. The vault, the relay and a simulated chain, Helix Anchor Devnet (0x48454c58, shown as “HELX devnet”), all live in localStorage. Code on this page that says “mirrors js/app/…” is taken from the demo. Anything marked production is the target design, not something the demo does yet.
Ten minutes with the demo
Open app.html and create a vault with demo data. The keys, 12 encrypted records, grants, kits and anchored history are generated in your browser. Then try these:
- Verify, then tamper. Records → open a record → Integrity → Verify integrity. Flip one bit of the stored ciphertext and verify again: the hash check, the Merkle proof and AES-GCM authentication all fail. Revert to restore it.
- Share with a clinician. Sharing → create a grant, then open Clinician view. It checks
ConsentRegistry, fetches the package from the relay, verifies the receipt signature and decrypts with the clinician's own key. - Revoke. Revoke the grant and reopen Clinician view. The package is gone from the relay and the chain shows the grant as revoked, so access fails.
- Connected sites. The Connected sites simulator calls every
mh_*method as a clinic portal would, through the same approval sheets. - Lab results. Kits → order a kit → Simulate next step until the lab releases the result. Then accept it: the wallet verifies the lab's signature, ECIES-decrypts the result and seals it as a new record.
- Ledger. Ledger → Verify chain recomputes every block hash, parent link and transaction root.
- Activity. Activity → Verify log re-walks the hash-chained audit log from the genesis hash.
- Recovery. Settings → reveal the recovery phrase, set guardians, add a passkey, export an encrypted backup, and try recovering with the phrase.
app.html also injects its own window.myhelix provider, so you can call it from the DevTools console. Approval sheets appear inside the app.
// On app.html, open DevTools and try the page's own provider
await myhelix.request({ method: 'mh_chainId' }); // '0x48454c58'
const [did] = await myhelix.request({ method: 'mh_requestAccounts' }); // approval sheet appears in the app
await myhelix.request({ method: 'mh_getPublicKeys' });
await myhelix.request({ method: 'mh_signMessage', params: { message: 'hello from the console' } });
myhelix.on('grantRevoked', (e) => console.log('revoked', e.grantId));Where every byte lives
Keys and plaintext stay on the patient's device. The relay stores and forwards ciphertext. The chain holds 32-byte commitments. Clinicians, labs and the Synthetic Insight enclave only get data through the provider, one scoped grant at a time.
The only place where MK, private keys and plaintext exist. Locks automatically.
Content-addressed ciphertext store and mailboxes. It enforces grant expiry and deletes packages when a grant is revoked.
Proves that a record existed unchanged at a given time, and that consent was granted or revoked. It can't reveal what either one was.
Privacy-preserving AI. The wallet checks the enclave's remote attestation, then wraps a grant key to the enclave's ECDH key, just as it would for a clinician.
One master key, many ways in
A random 256-bit Vault Master Key (MK) sits at the root. It never leaves the device unencrypted. Each unlock factor derives its own key-encryption key (KEK) and stores its own wrapped copy of MK. That means you can add or remove a factor without re-encrypting a single record.
| Key | Algorithm | Where it lives | Protects |
|---|---|---|---|
| Vault Master Key (MK) | 256-bit random, AES-GCM | Memory while unlocked. At rest only as wrapped copies (wrap.pass, wrap.recovery, wrap.passkeys[]). | Record DEKs, identity private keys and the sealed index |
| KEK_pass | PBKDF2-HMAC-SHA256, 16-byte salt, 600,000 iterations → AES-GCM-256 | Derived at unlock, never stored | Wrapped MK (passphrase copy) |
| KEK_passkey | WebAuthn PRF output (per-passkey salt) → HKDF-SHA256 (salt "myhelix", info "myhelix/passkey/v1") → AES-GCM-256 | Derived during the passkey ceremony, never stored. Demo: where the browser supports PRF. On mobile: Secure Enclave / StrongBox. | Wrapped MK (passkey copy) |
| Recovery seed | BIP-39: 128-bit entropy + 4-bit SHA-256 checksum → 12 words; seed = PBKDF2-HMAC-SHA512(NFKD(mnemonic), "mnemonic", 2048) → 64 bytes | On paper, with the patient. Demo only: also kept inside the sealed index so Settings can reveal it. Production would not store it. | Input to KEK_recovery |
| KEK_recovery | HKDF-SHA256(seed, salt "myhelix", info "myhelix/recovery/v1") → AES-GCM-256 | Derived during recovery, never stored | Wrapped MK (recovery copy) |
| Guardian shares | Shamir 2-of-3 over GF(2^8), polynomial 0x11b; format mhs1-<x>-<hex> | One share per guardian. Demo: shares kept in the sealed index. Production: each share ECIES-encrypted to its guardian and held only by them. | MK itself (any 2 shares rebuild it) |
| Signing key | ECDSA P-256, SHA-256 | PKCS#8 encrypted under MK (identity.signPriv) | Consent receipts, anchor transactions / UserOps, audit-log heads, mh_signMessage. Derives the DID and account address. |
| Key-agreement key | ECDH P-256 | PKCS#8 encrypted under MK (identity.encPriv) | Inbound lab results and grant packages (ECIES recipient) |
| Record DEK | 256-bit random, AES-GCM, 12-byte IV | Wrapped by MK inside the envelope (wdek) | One record's payload |
| Grant key | 256-bit random, AES-GCM | ECIES-wrapped to the grantee (epk, wiv, wct) | One filtered share package |
| ECIES ephemeral key | ECDH P-256 → HKDF-SHA256 (salt "myhelix", info "myhelix/ecies/v1") → AES-GCM-256 | Discarded after use. Only the public half (epk) is sent. | One message to one recipient |
| (Sealed index) | Not a separate key: AES-256-GCM under MK, fresh IV on every write | vault.sealed, rewritten on every change | All metadata: record index, profile, grants, kits, connections, settings, audit log |
Identity: DID and account address
The signing public key doubles as the patient's identity. The DID is did:key:z…, which is base58btc over the multicodec varint [0x80, 0x24] (0x1200, P-256) followed by the 33-byte compressed key. In the demo, the account address is 0x plus the last 20 bytes of SHA-256 over the uncompressed signing key. In production it stands for a counterfactual ERC-4337 smart-account address (see anchoring).
// did:key for a P-256 public key
const raw = new Uint8Array(await subtle.exportKey('raw', signingPublicKey)); // 0x04 || X || Y (65 bytes)
const compressed = new Uint8Array([raw[64] & 1 ? 0x03 : 0x02, ...raw.slice(1, 33)]);
const did = 'did:key:z' + base58btc(new Uint8Array([0x80, 0x24, ...compressed])); // 0x1200 as unsigned varint
// => did:key:zDnae...
// Demo account address (stands in for the counterfactual ERC-4337 smart-account address)
const address = '0x' + toHex((await sha256(raw)).slice(12)); // last 20 bytes// Passphrase -> KEK_pass (wraps MK)
const salt = crypto.getRandomValues(new Uint8Array(16)); // stored next to the wrapped MK
const base = await subtle.importKey('raw', utf8(passphrase.normalize('NFKC')), 'PBKDF2', false, ['deriveBits']);
const kekPass = await subtle.importKey('raw',
await subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 600_000 }, base, 256),
'AES-GCM', false, ['encrypt', 'decrypt']);
// BIP-39 recovery phrase -> KEK_recovery (wraps the same MK)
const seedBase = await subtle.importKey('raw', utf8(mnemonic.normalize('NFKD')), 'PBKDF2', false, ['deriveBits']);
const seed = await subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-512', salt: utf8('mnemonic'), iterations: 2048 }, seedBase, 512);
const hk = await subtle.importKey('raw', seed, 'HKDF', false, ['deriveBits']);
const kekRecovery = await subtle.importKey('raw',
await subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: utf8('myhelix'), info: utf8('myhelix/recovery/v1') }, hk, 256),
'AES-GCM', false, ['encrypt', 'decrypt']);The envelope
Each record (a lab panel, a genome report, a wearable export, a clinician note) is encrypted with its own random DEK, and the DEK is wrapped with MK. Because every record has a different key, sharing or deleting one record never touches the others. Deleting a record crypto-shreds it: the wrapped DEK and the ciphertext are both destroyed.
In the demo, the payload is app JSON that carries LOINC codes and UCUM-style units, and Settings exports everything as a FHIR R4 Bundle. Production payloads would be FHIR resources natively.
{
"kind": "lab_panel",
"date": "2026-08-02",
"label": "Blood 100 panel",
"source": "Binary Helix Lab Network · kit BH-7Q2D-9X",
"payload": {
"resourceType": "DiagnosticReport",
"org": "Binary Helix Lab Network",
"collected": "2026-08-02",
"results": [
{
"key": "ldl",
"code": "13457-7",
"name": "LDL-C",
"value": 138,
"unit": "mg/dL",
"ref": [
0,
130
],
"optimal": [
0,
100
]
}
]
}
}// Mirrors addRecord() + persist() in js/app/vault.js (MH.C = Web Crypto helpers)
async function addRecord(rec /* {kind, date, label, source, payload} */) {
const id = 'rec_' + C.toHex(C.rand(6));
// 1. Fresh 256-bit DEK; payload JSON -> AES-GCM with a random 12-byte IV
const dekRaw = C.rand(32);
const box = await C.aesEncrypt(await C.aesKeyFromRaw(dekRaw), JSON.stringify(rec.payload)); // {iv, ct} base64
// 2. Wrap the DEK under the Vault Master Key
const wdek = await C.aesEncrypt(S.mk, dekRaw);
// 3. Integrity hash over the CIPHERTEXT bytes (random IV + DEK => unlinkable)
const hash = await C.sha256hex(C.fromB64(box.ct));
// 4. The stored envelope carries no metadata at all
S.vault.records.push({ id, v: 1, iv: box.iv, ct: box.ct, wdek, hash, size: box.ct.length, createdAt: Date.now(), anchor: null });
// 5. kind / date / label / source go into the sealed index instead
S.sealed.index[id] = { kind: rec.kind, date: rec.date, label: rec.label, source: rec.source, addedAt: Date.now(), via: null };
S.vault.pending.push({ hash, kind: 'record', ref: id }); // next Merkle batch
await audit('Record encrypted & added', rec.label + ' · ' + rec.date);
await persist();
}
// persist(): the whole sealed index is re-encrypted under MK on every change
async function persist() {
S.vault.sealed = await C.aesEncrypt(S.mk, JSON.stringify(S.sealed)); // AES-256-GCM, fresh IV each time
S.vault.rev += 1;
localStorage.setItem('mh:vault:v1', JSON.stringify(S.vault)); // ciphertext-only vault blob
}{
"id": "rec_3f9a1c7e2b4d",
"v": 1,
"iv": "e6+MRDPaDCKvjZLd",
"ct": "fVkwTzh3tvlpnsL+rZNlZ3+xCAQQNbOYtUTn3Vsj6rrrb2cyfEP6vnJ5UXGExxohBuk3MPSgoEGKkBLlJ8faufQMTHCqOP49YnY0f/Ilea7th8TsTrXV0eCEM7oTdZFZfWV/tpubchTDOU03m92zGEdYcpDlBJ4UiE/e5vSM8skXcACAY9xg2RTKXoI412QGmyaGBP1U1TIkqcHuRgWd7K+XnCYtkAi/EWmKlGUPBfOkS1nyZrpaHjFPT4TT7kMJC3nyG4syUnPu3qgexMGqc+7tj2YMbTl5YXxj0VUnZYgNmPv2/aEyyVJAN20=",
"wdek": {
"iv": "pQ2mY7cVxK0rTa4L",
"ct": "0m5Q7Jb3yTqQn2d1bS0mJ8eYc6oZ3uVwq4P7lXf2s9kR1aHdG0nE5tBv8yWc3xMz"
},
"hash": "0x1d02aa809eb2afde290f8cf7fc92c3e3262660de0779c74d2997d242bba386f8",
"size": 316,
"createdAt": 1785657600000,
"anchor": {
"root": "0x0c5c259de9a3ae81fd3f7f310e8942f70c460faa12d52daae71382e6e278cc25",
"proof": [
{
"sibling": "0x6fd68459f58bc44c7e22d3491cd964e2f0c8ec2dc59de2dcede8b52fed4cd3e6",
"position": "R"
},
{
"sibling": "0x423a032124d082bbc8e0ccc3e117c27ecdfbb8eaa63c7ac7f802a61d79640fca",
"position": "L"
},
{
"sibling": "0x13ea166dc116ed48f958a335444d36c178948c4bbdac9ca32f52ab9650f91616",
"position": "R"
}
],
"txHash": "0x53ba41ed400e4e38fea31c8a817c88e11695a73ae10e4cebe9fd48a1f4bdaf48",
"block": 1842,
"at": 1785657612000
}
}| Field | Meaning |
|---|---|
id, v | Random record id (rec_ + 12 hex) and envelope version (1) |
iv, ct | 12-byte AES-GCM IV and ciphertext of the payload (base64). The GCM tag is appended to ct. |
wdek | The DEK encrypted under MK: {iv, ct} |
hash | SHA-256 of the ciphertext bytes, as 0x hex. Proves integrity and reveals nothing, because the IV and DEK are random. |
size, createdAt | Length of the base64 ciphertext, and creation time in milliseconds |
anchor | null until the next batch. Then root, proof ([{sibling, position:'L'|'R'}]), txHash, block and at (block time) |
The sealed index
The envelope has no kind, date or label. Everything that could describe a patient lives in a single sealed index: the record index (kind, date, label, source), profile, grants, kits, connected sites, settings and the audit log. In the demo it also holds the recovery phrase and guardian shares. The whole index is AES-256-GCM-encrypted under MK and rewritten, with a fresh IV, on every change (vault.sealed). A copy of the vault blob, whether in storage, on the relay or in a backup file, shows only ciphertext, wrapped keys, public keys and counts.
{
"profile": {
"name": "…",
"birthYear": 1982,
"sex": "…"
},
"index": {
"rec_3f9a1c7e2b4d": {
"kind": "lab_panel",
"date": "2026-08-02",
"label": "Blood 100 panel",
"source": "Binary Helix Lab Network · kit BH-7Q2D-9X",
"addedAt": 1785657600000,
"via": "kit BH-7Q2D-9X"
}
},
"grants": [
"…receipts, signatures, scopes, tx hashes…"
],
"kits": [
"…anonymous barcodes and status history…"
],
"connections": [
{
"origin": "portal.helixcare.clinic",
"name": "HelixCare Clinics portal",
"permissions": [
"accounts",
"requestAccess",
"submitRecord"
],
"connectedAt": 1785657600000
}
],
"settings": {},
"audit": [
"…hash-chained entries…"
],
"mnemonic": "… (demo only) …",
"backup": {
"phraseConfirmed": true,
"guardians": [
"…"
],
"shares": [
"mhs1-1-…",
"mhs1-2-…",
"mhs1-3-…"
],
"passkey": false
},
"dismissed": []
}{
"v": 1,
"app": "myhelix",
"createdAt": 1706775120000,
"did": "did:key:zDnaeTgKt9wNwDUyrTEu7pEEubn4mJAwcZCCWfe8pLEkJcfHs",
"address": "0x5bd27019df04102da4d187be16d1af5aba6cad7f",
"signPub": "<base64 raw P-256>",
"encPub": "<base64 raw P-256>",
"kdf": {
"alg": "PBKDF2-SHA256",
"iters": 600000,
"salt": "<base64 16 bytes>"
},
"wrap": {
"pass": {
"iv": "…",
"ct": "…"
},
"recovery": {
"iv": "…",
"ct": "…"
},
"passkeys": [
{
"credId": "…",
"salt": "…",
"box": {
"iv": "…",
"ct": "…"
}
}
]
},
"identity": {
"signPriv": {
"iv": "…",
"ct": "<PKCS#8 under MK>"
},
"encPriv": {
"iv": "…",
"ct": "<PKCS#8 under MK>"
}
},
"sealed": {
"iv": "…",
"ct": "<sealed index under MK>"
},
"records": [
"<envelopes>"
],
"pending": [
"<hashes awaiting a batch>"
],
"rev": 42
}The example values match each other. SHA-256 of the base64-decoded ct equals hash, and folding proof gives root under the domain-separated scheme. Paste them into your own verifier to test it.
Getting back in, without a back door
Binary Helix can't reset your wallet. That is the whole point, and it means recovery has to be designed in from day one. Each method below wraps or rebuilds the same MK. Patients should set up at least two.
Recovery = a factor + the encrypted vault. A phrase, passkey or pair of guardian shares recovers MK. It doesn't regenerate your data or identity keys. Those live in the encrypted vault blob (identity, sealed, records), so you also need a copy of it. In the demo, that copy is this browser's localStorage or an encrypted backup file you exported and then imported. In production, it's the ciphertext copy on the relay. The phrase unwraps wrap.recovery, then the wallet decrypts the identity keys and the sealed index under MK and asks for a new passphrase.
| Method | How it works | Good at | Honest trade-off |
|---|---|---|---|
| Passphrase | PBKDF2-HMAC-SHA256, 600,000 iterations → KEK_pass | Works everywhere, including the demo | A weak passphrase can be brute-forced offline if someone steals the wrapped MK. Easy to forget. |
| Passkey (WebAuthn PRF) | The authenticator returns a PRF secret for a per-passkey salt → HKDF → KEK_passkey. Available in the demo where the browser supports PRF. | Nothing to remember. Resists phishing. Biometric. | PRF support still varies by browser, OS and security key. Synced passkeys are only as secure as the account that syncs them. Keep a second factor. |
| BIP-39 phrase | 12 words (128-bit entropy) → seed → HKDF → KEK_recovery, which unwraps wrap.recovery | Survives losing every device, as long as an encrypted vault copy exists | Useless without the vault blob. Anyone who has the paper and a copy of the blob gets full access. It has to be written down correctly and stored safely. |
| Guardians, 2-of-3 | MK split with Shamir over GF(2^8). Any 2 shares rebuild MK, which then opens the vault blob. Production encrypts each share to its guardian. | No single point of failure. One lost or compromised guardian is harmless. | Two guardians who collude can rebuild MK. Guardians have to stay reachable and trustworthy for years. |
// Mirrors unlockWithPasskey() in js/app/vault.js - no secret to remember
const pk = vault.wrap.passkeys[0]; // { credId, salt (32 random bytes, b64), box: wrapped MK }
const cred = await navigator.credentials.get({
publicKey: {
challenge: C.rand(32),
allowCredentials: [{ type: 'public-key', id: C.fromB64(pk.credId) }],
userVerification: 'required',
extensions: { prf: { eval: { first: C.fromB64(pk.salt) } } }
}
});
const prf = cred.getClientExtensionResults().prf?.results?.first; // 32 bytes
if (!prf) throw new Error('This passkey provider does not support the PRF extension - use the passphrase');
const kekPasskey = await C.aesKeyFromRaw(await C.hkdf(new Uint8Array(prf), 'myhelix/passkey/v1')); // HKDF-SHA256, salt "myhelix"
const mkRaw = await C.aesDecrypt(kekPasskey, pk.box);// 2-of-3 split of the 32-byte MK over GF(2^8), reduction polynomial x^8+x^4+x^3+x+1 (0x11b)
const shares = shamirSplit(mkRaw, /* n */ 3, /* k */ 2);
// [ 'mhs1-1-<64 hex chars>', 'mhs1-2-<64 hex chars>', 'mhs1-3-<64 hex chars>' ]
// Production: never hand a guardian a plaintext share - encrypt it to their ECDH key
const sealed = await eciesEncrypt(guardian.encryptionKeyRaw, utf8(shares[1])); // { epk, iv, ct }
// Recovery: any two shares reconstruct MK (Lagrange interpolation at x = 0)
const mkRaw2 = shamirCombine(['mhs1-1-…', 'mhs1-3-…']);If every factor is lost, or every copy of the encrypted vault, the data is gone. There's no custodial reset. Lab data can be re-issued by the lab, but notes, imports and history stored only in the wallet can't be. The app asks patients to confirm a second factor before they import anything important.
From lab to wallet, sealed the whole way
Labs never learn who a sample belongs to. They see a barcode and return results encrypted to a key only the patient's wallet holds.
- Anonymous kit. Every kit carries a random barcode, such as
BH-7Q2D-9X. When the patient scans it, the wallet stores the barcode-to-person link in the encrypted vault. That link doesn't exist anywhere else. - Registration. The wallet registers the barcode with a relay mailbox and the wallet's ECDH P-256 public key. Recommended for production: use a separate ECDH key per kit, so that one lab can't link a patient's repeat kits.
- Encrypt at the lab. The lab encrypts the result with ECIES: an ephemeral ECDH P-256 key pair, then HKDF-SHA256 (salt
"myhelix", info"myhelix/ecies/v1"), then AES-GCM-256. The result is{epk, iv, ct}. In the demo, the plaintext is{kind, date, label, source, payload}. In production, it's a FHIR R4 Bundle. - Sign. The lab signs
canonical(envelope)with its ECDSA P-256 key. - Deliver. The lab either posts the envelope and signature to the relay inbox for that kit, or calls
mh_submitRecordwhile the patient has the lab portal open. - Import. When a signature is present, the wallet verifies it before decrypting and rejects the result if it fails. It then ECIES-decrypts with its own ECDH key, seals the result as a normal envelope under a fresh DEK, adds it to the sealed index and queues its ciphertext hash for anchoring.
// Lab side (browser or Node 20+). Mirrors eciesEncrypt() in js/app/crypto.js
const subtle = globalThis.crypto.subtle;
const utf8 = (s) => new TextEncoder().encode(s);
const toB64 = (b) => btoa(String.fromCharCode(...new Uint8Array(b)));
async function hkdf(secret, info) {
const base = await subtle.importKey('raw', secret, 'HKDF', false, ['deriveBits']);
return new Uint8Array(await subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: utf8('myhelix'), info: utf8(info) }, base, 256));
}
async function eciesEncrypt(recipientRaw /* 65-byte uncompressed P-256 point */, plaintext /* string */) {
const eph = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
const recipient = await subtle.importKey('raw', recipientRaw, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
const shared = new Uint8Array(await subtle.deriveBits({ name: 'ECDH', public: recipient }, eph.privateKey, 256));
const key = await subtle.importKey('raw', await hkdf(shared, 'myhelix/ecies/v1'), 'AES-GCM', false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await subtle.encrypt({ name: 'AES-GCM', iv }, key, utf8(plaintext));
return { epk: toB64(await subtle.exportKey('raw', eph.publicKey)), iv: toB64(iv), ct: toB64(ct) };
}
// Demo plaintext: the record the wallet will seal (production: a FHIR R4 Bundle)
const result = { kind: 'lab_panel', date: '2026-09-14', label: 'Pulse Kit · 8 markers',
source: 'Binary Helix Lab Network · kit BH-7Q2D-9X', payload: diagnosticReport };
const envelope = await eciesEncrypt(walletEncRaw /* from mh_getPublicKeys().encryption.raw */, JSON.stringify(result));
// Sign canonical(envelope) with the lab's ECDSA P-256 key; the wallet verifies BEFORE decrypting
const sig = await subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, labSigningKey, utf8(canonical(envelope)));
const signature = '0x' + [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join(''); // raw r||s
await myhelix.request({
method: 'mh_submitRecord',
params: { envelope, source: 'Binary Helix Lab Network', kitId: 'BH-7Q2D-9X', signature, signerPublicKey: labSignRawB64 }
});
// ...or post the same {env, labSig, labSignPub} to the relay inbox for that kit (no name, no DOB, no email).In the demo, the lab's signing key comes from the demo directory. In production, the key is tied to a Verifiable Credential for the lab (see data standards), so the wallet can show a “verified lab” badge.
Scoped, signed, expiring grants
To share, the patient picks a recipient from the verified directory (a clinician DID and ECDH public key), a scope (record kinds, specific markers, a date range, and whether to include sensitive genetic findings), a purpose and a duration: 24 hours, 72 hours, 7 days or 30 days. The wallet then decrypts only the matching records and builds a package with exactly what the scope allows. When a site asks through mh_requestAccess, the approval sheet lets the patient narrow the scope, purpose and duration before anything is signed.
The grant package
{
"kinds": [
"lab_panel"
],
"markers": [
"apob",
"ldl",
"a1c"
],
"from": "2025-01-01"
}// Mirrors createGrant() in js/app/vault.js - runs after the patient approves (and possibly narrows) the request
async function createGrant({ granteeId, scope, purpose, hours, shareName }) {
const cp = counterparty(granteeId); // verified directory entry: did, encPub
const pkg = await buildPackage(scope); // decrypts matching records, filters markers
const grantId = await C.sha256hex(C.rand(32));
const grantKeyRaw = C.rand(32); // fresh per grant
// Package plaintext (demo JSON; production can emit a FHIR R4 Bundle)
const box = await C.aesEncrypt(await C.aesKeyFromRaw(grantKeyRaw), JSON.stringify({
patient: shareName ? S.sealed.profile.name : 'MyHelix member',
scope,
items: pkg.items // [{kind, date, label, source, hash, data}]
}));
const wrapped = await C.eciesEncrypt(C.fromB64(cp.encPub), grantKeyRaw); // {epk, iv, ct}
const now = Math.floor(Date.now() / 1000);
const receipt = {
type: 'MyHelixConsent', version: '1', chainId: '0x48454c58', verifyingContract: CONSENT_REGISTRY,
grantId, grantor: S.vault.did, grantee: cp.did, scopeHash: await C.sha256hex(C.canonical(scope)),
purpose, issuedAt: now, expiresAt: now + hours * 3600, nonce: C.toHex(C.rand(8))
};
const signature = await C.sign(S.signPriv, C.canonical(receipt)); // "0x" + raw r||s
const receiptHash = await C.sha256hex(C.canonical(receipt));
const granteeCommitment = await C.sha256hex(cp.did + receipt.nonce); // SHA-256(UTF-8(did + nonce))
const { tx } = await ledger.submit('ConsentRegistry', 'grant',
{ grantId, grantee: granteeCommitment, receiptHash, expiresAt: receipt.expiresAt });
relay.grants[grantId] = { receipt, signature, grantorSignPub: S.vault.signPub, wrappedKey: wrapped, box };
S.vault.pending.push({ hash: receiptHash, kind: 'receipt', ref: grantId }); // also Merkle-anchored
await audit('Access granted', cp.name + ' · ' + pkg.count + ' records · ' + purpose);
await persist();
// Provider result: package = { epk: wrapped.epk, wiv: wrapped.iv, wct: wrapped.ct, iv: box.iv, ct: box.ct }
}The package has five fields. iv and ct are the package body encrypted under the grant key. In the demo, the body is JSON {patient, scope, items:[{kind, date, label, source, hash, data}]}, where patient is the patient's name only if they tick “include my name”, and “MyHelix member” otherwise. Production can emit a FHIR R4 Bundle instead. epk, wiv and wct are the grant key itself, ECIES-wrapped to the grantee's ECDH key. Only the recipient's private key can open it.
The consent receipt
The receipt is canonical JSON (keys sorted, no whitespace) signed with the wallet's ECDSA P-256 key. The signature is 0x plus the raw r‖s. The receipt's SHA-256 hash is registered on-chain with ConsentRegistry.grant(grantId, grantee, receiptHash, expiresAt), where grantee is the commitment SHA-256(UTF-8(granteeDID + nonce)): plain string concatenation of the DID and the nonce hex. The receipt hash is also added as a leaf to the next anchor batch.
{
"type": "MyHelixConsent",
"version": "1",
"chainId": "0x48454c58",
"verifyingContract": "0xc0a5e7b1d9f3a2c64e8b7d1f0a9c3e5b2d6f8a17",
"grantId": "0x5c1fa0d3e7b24c9a86f1d2e3b4a5968778695a4b3c2d1e0f9a8b7c6d5e4f3a2b",
"grantor": "did:key:zDnaeTgKt9wNwDUyrTEu7pEEubn4mJAwcZCCWfe8pLEkJcfHs",
"grantee": "did:key:zDnaerx9CtbPJ1q36T5Ln5wYt3MQYeGRG5ehnPAmxcf5mDZpv",
"scopeHash": "0xe17d862d61e2f77cade7c0a16aa946f9faf765b575a4d3fa8857edb9a6d0ce9e",
"purpose": "Treatment",
"issuedAt": 1789560000,
"expiresAt": 1789819200,
"nonce": "8e2b4f6a1c3d5e7f"
}For this exact receipt, scopeHash = 0xe17d862d61e2f77cade7c0a16aa946f9faf765b575a4d3fa8857edb9a6d0ce9e, receiptHash = SHA-256(canonical(receipt)) = 0xad11be843421ad431a327ae1f77eaf8e1eab368f8cbbc58ff09e4f88b636a826, and the on-chain grantee commitment = 0x5a98ebe61f47a7c78cbb7458d56ddf87b7fbf50ceb94116b5b399f7be7435d45.
The receipt is EIP-712-shaped. It carries its own domain fields (version, chainId, verifyingContract), so a receipt can't be replayed on another network or registry, and its fields map onto EIP-712 types. Today the signature is ECDSA P-256 over the canonical JSON bytes, not over the keccak EIP-712 digest.
{
"domain": {
"name": "MyHelix",
"version": "1",
"chainId": "0x48454c58",
"verifyingContract": "0xc0a5e7b1d9f3a2c64e8b7d1f0a9c3e5b2d6f8a17"
},
"primaryType": "MyHelixConsent",
"types": {
"MyHelixConsent": [
{
"name": "grantId",
"type": "bytes32"
},
{
"name": "grantor",
"type": "string"
},
{
"name": "grantee",
"type": "string"
},
{
"name": "scopeHash",
"type": "bytes32"
},
{
"name": "purpose",
"type": "string"
},
{
"name": "issuedAt",
"type": "uint64"
},
{
"name": "expiresAt",
"type": "uint64"
},
{
"name": "nonce",
"type": "string"
}
]
},
"message": "<receipt fields other than type / version / chainId / verifyingContract>"
}Revocation, and its limit
Revoking a grant does three things. It deletes the package from the relay, calls ConsentRegistry.revoke(grantId) (sponsored), and adds an audit entry. Connected sites receive a grantRevoked event, and anything that checks isActive(grantId) sees false from the next block. Expiry works the same way, without needing a transaction.
Revocation stops future access. It can't claw back data that a clinician has already viewed or saved. The receipt, the audit log and the on-chain record prove what was shared, with whom, for what purpose and until when. That evidence is what makes misuse accountable. The patient app says this plainly before every share.
Audit log
Every unlock, import, grant, revoke, anchor and setting change adds an entry to an append-only, hash-chained log inside the sealed index. Each entry is {i, ts, actor, action, detail, prev}, and entry.hash = SHA-256(UTF-8(prevHash + canonical(entry without its hash))), starting from a genesis prev of 32 zero bytes. The current head hash is added as an extra leaf to every anchoring batch. Rewriting any past entry then breaks both the chain and the anchored proof.
// Mirrors audit() / verifyAudit() in js/app/vault.js
const GENESIS = '0x' + '0'.repeat(64);
async function audit(action, detail, actor = 'You') {
const log = S.sealed.audit; // lives inside the sealed index
const prev = log.length ? log[log.length - 1].hash : GENESIS;
const e = { i: log.length, ts: Date.now(), actor, action, detail, prev };
e.hash = await C.sha256hex(prev + C.canonical(e)); // SHA-256(UTF-8(prevHash + canonical(entry-without-hash)))
log.push(e);
}
async function verifyAudit(log) {
let prev = GENESIS;
for (const { hash, ...body } of log) {
if (body.prev !== prev || await C.sha256hex(prev + C.canonical(body)) !== hash) return { ok: false, at: body.i };
prev = hash;
}
return { ok: true, head: prev }; // head is added as a leaf to every anchor batch
}Tamper-evidence without exposure
The ledger answers two questions: did this exact record exist, unchanged, at this time? and was consent granted, and is it still active? Neither answer needs the data. Pending record hashes and receipt hashes, plus the current audit-log head as an extra leaf, are batched into a binary SHA-256 Merkle tree. One transaction anchors the whole batch.
Batching and verification
- Hashes wait in
vault.pending. In the demo, a batch is anchored about 6 seconds after the last change, or immediately when you click the pending-hashes pill (“Anchor now”). A production batcher would also batch across patients. - One call,
HelixAnchor.anchor(bytes32 root, uint32 leafCount, bytes32 batchId), anchors the batch. The cost is the same for 1 leaf or 100,000. - Each record's envelope stores its
anchor:{root, proof:[{sibling, position}], txHash, block, at}. Grants store the same thing for their receipt, and the sealed index stores it for the anchored audit head. - To verify: recompute SHA-256(ct), fold the proof, compare the result with the root, then look up
HelixAnchor.roots(root), which returns the timestamp. Flip one byte of ciphertext and verification fails. The demo's ledger page lets you try it.
// Mirrors merkle() / verifyProof() in js/app/crypto.js and anchorPending() in js/app/vault.js
const leafHash = (h) => C.sha256hex(C.concat(new Uint8Array([0x00]), C.fromHex(h)));
const nodeHash = (l, r) => C.sha256hex(C.concat(new Uint8Array([0x01]), C.fromHex(l), C.fromHex(r)));
async function merkle(hashes) {
let level = await Promise.all(hashes.map(leafHash));
const proofs = hashes.map(() => []), pos = hashes.map((_, i) => i);
while (level.length > 1) {
const next = [];
for (let i = 0; i < level.length; i += 2) {
next.push(i + 1 < level.length ? await nodeHash(level[i], level[i + 1]) : level[i]); // odd node promoted
}
pos.forEach((p, leaf) => {
const sib = p ^ 1;
if (sib < level.length) proofs[leaf].push({ sibling: level[sib], position: sib < p ? 'L' : 'R' });
pos[leaf] = p >> 1;
});
level = next;
}
return { root: level[0], proofs };
}
// One batch = pending record hashes + receipt hashes + the CURRENT AUDIT-LOG HEAD as an extra leaf
async function anchorPending() {
const items = S.vault.pending.slice();
const log = S.sealed.audit;
if (log.length) items.push({ hash: log[log.length - 1].hash, kind: 'audit', ref: 'audit:' + (log.length - 1) });
const tree = await merkle(items.map((x) => x.hash));
const { tx, block } = await ledger.submit('HelixAnchor', 'anchor', { root: tree.root, leafCount: items.length, batchId });
items.forEach((it, i) => attachAnchor(it, { root: tree.root, proof: tree.proofs[i], txHash: tx.hash, block: block.number, at: block.timestamp }));
S.vault.pending = [];
}
// Verify one record end-to-end
async function verifyRecord(r) {
const h = await C.sha256hex(C.fromB64(r.ct)); // 1. recompute from ciphertext
if (h !== r.hash) return false; // flipped bit => fails here
let acc = await leafHash(h); // 2. fold the proof
for (const { sibling, position } of r.anchor.proof) acc = position === 'L' ? await nodeHash(sibling, acc) : await nodeHash(acc, sibling);
if (acc !== r.anchor.root) return false;
return (await helixAnchor.roots(r.anchor.root)) > 0n; // 3. root known on chain
}
// On-chain equivalent: HelixAnchor.verifyProof(hash, proof.map(p => p.sibling), proof.map(p => p.position === 'L'), root)Why only hashes
- Blockchains are public and permanent. Nothing that could ever be personal data belongs on one, including encrypted data, because today's ciphertext has to survive tomorrow's cryptanalysis.
- A SHA-256 hash of AES-GCM ciphertext under a random key and IV reveals nothing about the plaintext. Two identical lab results produce unrelated hashes.
- Merkle batching hides even the number of records a patient has. Many patients' hashes share one root.
Network choice: Base
Production anchoring targets Base, an Ethereum L2. Roots are only 32 bytes, so blobs aren't needed. We chose Base for four reasons:
- Fees are low enough to anchor frequently, and security settles to Ethereum.
- The RIP-7212 P-256 signature-verification precompile is live on Base and several other major L2s, which makes passkey-signed smart accounts cheap. Ethereum mainnet added an equivalent precompile (EIP-7951) in the Fusaka upgrade in December 2025.
- ERC-4337 bundlers and paymasters are mature there.
- It's EVM-compatible, so we can move or mirror to another chain.
Honest caveat: Base currently runs a single sequencer, which is a liveness and censorship risk. Anchoring tolerates short delays, and we can optionally mirror roots to a second chain for redundancy.
Accounts: ERC-4337, P-256 and a paymaster
Patients never see a seed phrase for the chain, never buy gas and never hold a token.
- Smart account. Each patient gets a counterfactual ERC-4337 smart account. Its owner is a P-256 key: the wallet's signing key and, in production, a passkey.
- On-chain verification. Signatures are checked on-chain through the RIP-7212 precompile.
- Sponsored gas. A Binary Helix paymaster sponsors gas under a narrow policy: only
ConsentRegistryandHelixAnchorcalls, with rate limits.
// Sponsored consent registration from the patient's smart account (ERC-4337)
const callData = smartAccount.encodeExecute({
to: CONSENT_REGISTRY,
data: encodeFunctionData({ abi: consentRegistryAbi, functionName: 'grant',
args: [grantId, granteeCommitment, receiptHash, BigInt(expiresAt)] })
});
const userOp = await bundler.prepareUserOperation({
sender: smartAccount.address,
callData,
paymaster: BINARY_HELIX_PAYMASTER // policy: only HelixAnchor / ConsentRegistry calls, rate limited
});
// Owner signature is P-256 (wallet signing key or passkey), checked on-chain by the RIP-7212 precompile (0x…0100)
userOp.signature = await smartAccount.signUserOperation(userOp);
const txHash = await bundler.sendUserOperation(userOp);No token. No NFTs of health data. The chain is a notary, not a marketplace. Health data and identifiers never go on-chain. A smart-account address is pseudonymous, and ConsentRegistry stores only a salted commitment to the grantee.
window.myhelix reference
The provider has the same shape as EIP-1193. There's one request({method, params}) that returns a Promise, plus on and removeListener for events. The extension injects it, and the mobile app exposes the same RPC over a paired session. app.html injects its own copy (see Try it).
If the wallet is locked when a site calls anything other than mh_chainId or mh_accounts, the wallet first shows an unlock prompt, the same way MetaMask does. The call fails with 4900 only if the patient doesn't unlock.
Methods
Ask to connect. Grants a per-origin permission to see the DID and public keys, and nothing else.
- params
- {name?} (display name for the site)
- returns
- [did]
The connected DID, or an empty array if not connected or locked. Never prompts.
- params
- none
- returns
- [did] | []
The anchoring network. The demo returns Helix Anchor Devnet.
- params
- none
- returns
- "0x48454c58"
Public keys for verifying receipts and encrypting results to the wallet. Each key is {kty:'EC', crv:'P-256', alg, raw}, where raw is the base64 uncompressed point (alg is ES256 or ECDH-ES).
- params
- none
- returns
- {did, address, signing:{kty, crv, alg, raw}, encryption:{kty, crv, alg, raw}}
Request a scoped, time-limited grant. grantee.did is resolved against the verified directory, and the directory's encryption key is used; unknown grantees get 4100. The patient can narrow scope, duration and purpose before signing, so the returned receipt may differ from the request.
- params
- {grantee:{did, name, encryptionKey?}, scope:{kinds[], markers[], from?, to?, includeSensitive?}, purpose, durationHours}
- returns
- {grantId, receipt, signature, package:{epk, iv, ct, wiv, wct}, expiresAt, txHash}
Revoke a grant (for example, when a clinic closes a care episode). Also emits grantRevoked.
- params
- {grantId}
- returns
- {txHash}
Deliver a result encrypted to the wallet's ECDH key. The patient approves the import. If signature is present (ECDSA P-256 over canonical(envelope), verified with signerPublicKey, base64 raw), the wallet checks it before decrypting.
- params
- {envelope:{epk, iv, ct}, source, kitId?, signature?, signerPublicKey?}
- returns
- {recordId, hash}
ECDSA P-256 signature over UTF-8 "MyHelix Signed Message:\n" + message. The prefix stops a site from getting a receipt or transaction signed under disguise.
- params
- {message}
- returns
- {signature, did}
Check whether a ciphertext hash is anchored, and fold its proof. Unknown hashes return {anchored:false, valid:false}.
- params
- {hash}
- returns
- {anchored, root, proof, txHash, block, valid}
type DID = `did:key:z${string}`;
type Hex = `0x${string}`;
type B64 = string; // standard base64
type RawP256Key = { kty: 'EC'; crv: 'P-256'; alg: 'ES256' | 'ECDH-ES'; raw: B64 }; // raw = 65-byte uncompressed point
interface MyHelixProvider {
readonly isMyHelix: true;
request<M extends keyof Methods>(args: { method: M; params?: Methods[M]['params'] }): Promise<Methods[M]['result']>;
on<E extends keyof Events>(event: E, listener: (payload: Events[E]) => void): void;
removeListener<E extends keyof Events>(event: E, listener: (payload: Events[E]) => void): void;
}
interface Methods {
mh_requestAccounts: { params?: { name?: string }; result: [DID] }; // connect prompt
mh_accounts: { params?: undefined; result: [DID] | [] };
mh_chainId: { params?: undefined; result: '0x48454c58' };
mh_getPublicKeys: { params?: undefined; result: { did: DID; address: Hex; signing: RawP256Key; encryption: RawP256Key } };
mh_requestAccess: { params: AccessRequest; result: AccessResult }; // approval UI (user may narrow)
mh_revokeAccess: { params: { grantId: Hex }; result: { txHash: Hex } };
mh_submitRecord: { params: { envelope: { epk: B64; iv: B64; ct: B64 }; source: string; kitId?: string;
signature?: Hex; signerPublicKey?: B64 }; // ECDSA P-256 over canonical(envelope)
result: { recordId: string; hash: Hex } }; // approval UI
mh_signMessage: { params: { message: string }; result: { signature: Hex; did: DID } }; // approval UI
mh_verifyRecord: { params: { hash: Hex };
result: { anchored: boolean; valid: boolean; root?: Hex; proof?: ProofStep[]; txHash?: Hex; block?: number } };
}
interface AccessRequest {
grantee: { did: DID; name: string; encryptionKey?: B64 }; // resolved by did against the verified directory; unknown => 4100
scope: { kinds: string[]; markers: string[]; from?: string; to?: string; includeSensitive?: boolean };
purpose: string;
durationHours: number; // e.g. 24, 72, 168, 720
}
interface AccessResult {
grantId: Hex;
receipt: ConsentReceipt; // reflects what the patient APPROVED, which may be narrower than requested
signature: Hex; // ECDSA P-256 over UTF-8(canonical(receipt)), raw r||s
package: { epk: B64; wiv: B64; wct: B64; iv: B64; ct: B64 };
expiresAt: number; // unix seconds
txHash: Hex; // ConsentRegistry.grant
}
interface ConsentReceipt {
type: 'MyHelixConsent'; version: '1'; chainId: '0x48454c58'; verifyingContract: Hex;
grantId: Hex; grantor: DID; grantee: DID; scopeHash: Hex;
purpose: string; issuedAt: number; expiresAt: number; nonce: string; // nonce: 16 hex chars
}
type ProofStep = { sibling: Hex; position: 'L' | 'R' };
interface Events {
connect: { chainId: '0x48454c58' };
disconnect: { code: number; message: string };
accountsChanged: DID[];
lock: void;
unlock: void;
grantRevoked: { grantId: Hex };
}Errors
| Code | Name | When | What your site should do |
|---|---|---|---|
| 4001 | User rejected | The patient declined, or closed the approval window | Say so politely. Don't retry in a loop. |
| 4100 | Unauthorized | The origin isn't connected, the grantee isn't in the verified directory, or the grant is unknown | Call mh_requestAccounts first, and register your clinic in the directory |
| 4200 | Unsupported method | Unknown method, or not available on this surface | Feature-detect and degrade gracefully |
| 4900 | Locked | The wallet is locked and the patient dismissed the unlock prompt | Ask the patient to unlock, then try again |
Errors are Error objects with a numeric code and optional data. The extension skeleton also uses the standard JSON-RPC codes −32602 (invalid params) and −32603 (internal error).
Events
| Event | Payload | Fired when |
|---|---|---|
connect | {chainId} | The origin is connected |
disconnect | {code, message} | The patient removed the site's permission |
accountsChanged | [did] or [] | Connect, disconnect, lock or unlock |
lock / unlock | none | Manual lock, auto-lock timer, OS screen lock, unlock |
grantRevoked | {grantId} | A grant to this origin was revoked |
Discovery
Don't rely on a global that other extensions might overwrite. MyHelix follows the EIP-6963 pattern with its own event names. Your page dispatches myhelix:requestProvider, and every installed MyHelix provider (extension or paired mobile bridge) answers with myhelix:announceProvider. The answer's detail is {info:{uuid, name, icon, rdns}, provider}, and the rdns is bio.binaryhelix.myhelix.
// Discover MyHelix without relying on a global (EIP-6963 style)
function discoverMyHelix(timeoutMs = 800) {
return new Promise((resolve, reject) => {
const onAnnounce = (event) => {
window.removeEventListener('myhelix:announceProvider', onAnnounce);
clearTimeout(timer);
resolve(event.detail); // { info: { uuid, name, icon, rdns: 'bio.binaryhelix.myhelix' }, provider }
};
const timer = setTimeout(() => {
window.removeEventListener('myhelix:announceProvider', onAnnounce);
reject(new Error('MyHelix wallet not found'));
}, timeoutMs);
window.addEventListener('myhelix:announceProvider', onAnnounce);
window.dispatchEvent(new Event('myhelix:requestProvider'));
});
}Integration example: a clinic portal
This is the full flow: connect, request access, verify the receipt, then decrypt the package on the clinic's side with the clinic's own ECDH key. The wallet never sees the clinic's private key, and the clinic never sees anything outside the approved scope.
// clinic-portal/myhelix.js - HelixCare Clinics style integration
const subtle = crypto.subtle;
const utf8 = (s) => new TextEncoder().encode(s);
const fromB64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
const fromHex = (h) => Uint8Array.from(h.replace(/^0x/, '').match(/../g), (b) => parseInt(b, 16));
const canonical = (v) => v === null || typeof v !== 'object' ? JSON.stringify(v)
: Array.isArray(v) ? '[' + v.map(canonical).join(',') + ']'
: '{' + Object.keys(v).filter((k) => v[k] !== undefined).sort().map((k) => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}';
const hkdf = async (secret, info) => new Uint8Array(await subtle.deriveBits(
{ name: 'HKDF', hash: 'SHA-256', salt: utf8('myhelix'), info: utf8(info) },
await subtle.importKey('raw', secret, 'HKDF', false, ['deriveBits']), 256));
// The clinic must be listed in the verified MyHelix directory with this DID and ECDH P-256 key.
// Its private key stays in the clinic's KMS/HSM or on the clinician's device - never in the wallet.
const CLINIC = { did: 'did:key:zDnaerx9CtbPJ1q36T5Ln5wYt3MQYeGRG5ehnPAmxcf5mDZpv', name: 'HelixCare Clinics' };
export async function requestPatientRecords(clinicEcdhPrivateKey, clinicEncPubB64) {
// 1. Discover + connect
const { provider } = await discoverMyHelix();
if (await provider.request({ method: 'mh_chainId' }) !== '0x48454c58') throw new Error('Unexpected network');
const [patientDid] = await provider.request({ method: 'mh_requestAccounts', params: { name: CLINIC.name } });
const { signing } = await provider.request({ method: 'mh_getPublicKeys' });
// 2. Ask for scoped, time-limited access
let grant;
try {
grant = await provider.request({
method: 'mh_requestAccess',
params: {
grantee: { ...CLINIC, encryptionKey: clinicEncPubB64 },
scope: { kinds: ['lab_panel'], markers: ['apob', 'ldl', 'a1c'], from: '2025-01-01' },
purpose: 'Treatment',
durationHours: 72
}
});
} catch (err) {
if (err.code === 4001) return void showNotice('The patient declined the request.');
if (err.code === 4100) return void showNotice('Not connected, or this clinic is not in the verified directory.');
if (err.code === 4900) return void showNotice('Ask the patient to unlock MyHelix, then try again.');
throw err;
}
// 3. Verify the signed receipt. The patient may have narrowed scope, purpose or duration - use the RECEIPT, not your request.
const verifyKey = await subtle.importKey('raw', fromB64(signing.raw), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
const receiptOk = await subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, verifyKey, fromHex(grant.signature), utf8(canonical(grant.receipt)));
if (!receiptOk || grant.receipt.grantor !== patientDid || grant.receipt.grantee !== CLINIC.did) {
throw new Error('Consent receipt failed verification');
}
// 4. Open the package with the clinic's own ECDH private key
const data = await openPackage(clinicEcdhPrivateKey, grant.package); // { patient, scope, items: [{kind, date, label, source, hash, data}] }
renderChart(data, { expiresAt: grant.receipt.expiresAt, purpose: grant.receipt.purpose });
// 5. Respect revocation and expiry
provider.on('grantRevoked', ({ grantId }) => { if (grantId === grant.grantId) purgeFromSession(grantId); });
// Server side, before every re-fetch from the relay: ConsentRegistry.isActive(grantId) must be true.
return data;
}
async function openPackage(privateKey, pkg) {
// ECIES unwrap of the grant key: ECDH(clinic priv, epk) -> HKDF-SHA256("myhelix/ecies/v1") -> AES-GCM(wiv, wct)
const epk = await subtle.importKey('raw', fromB64(pkg.epk), { name: 'ECDH', namedCurve: 'P-256' }, false, []);
const shared = new Uint8Array(await subtle.deriveBits({ name: 'ECDH', public: epk }, privateKey, 256));
const wrapKey = await subtle.importKey('raw', await hkdf(shared, 'myhelix/ecies/v1'), 'AES-GCM', false, ['decrypt']);
const grantKeyRaw = await subtle.decrypt({ name: 'AES-GCM', iv: fromB64(pkg.wiv) }, wrapKey, fromB64(pkg.wct));
// Package body: AES-GCM(grant key, iv, ct)
const grantKey = await subtle.importKey('raw', grantKeyRaw, 'AES-GCM', false, ['decrypt']);
const plain = await subtle.decrypt({ name: 'AES-GCM', iv: fromB64(pkg.iv) }, grantKey, fromB64(pkg.ct));
return JSON.parse(new TextDecoder().decode(plain));
}- Get listed in the directory. The wallet encrypts to the key in the verified directory, not to whatever key a page sends. Unknown grantees get
4100. - Keep the clinic key safe. Anyone with the clinic's ECDH private key can open packages sent to it. Use a KMS or HSM, or a clinician device key, and rotate it through the directory.
- Trust the receipt, not your request. The patient may have narrowed scope, purpose or duration.
- Check consent before every re-fetch. Call
ConsentRegistry.isActive(grantId)and confirm thatmatchesReceipt(grantId, receiptHash)is true. - Handle PHI as PHI. Store decrypted data only in your EHR under your HIPAA obligations, and record the
grantIdnext to them.
Manifest V3, split by trust
The extension is split into four contexts, each with the least privilege it needs. The page and the provider script never touch keys.
inpage.js, the page's MAIN worldDefines window.myhelix and announces it. It only sends messages. It holds no state worth stealing.
content.js, the isolated worldBridges window.postMessage to a chrome.runtime Port, filtering on source === window and a target tag.
background.js, the service workerHolds the unlocked session in chrome.storage.session and per-origin permissions, dispatches RPC and handles auto-lock with chrome.alarms and chrome.idle. It reads the caller's origin from port.sender, never from the page.
The same UI bundle as the web app, in shell=extension layout. Approvals open with chrome.windows.create({type:'popup'}), so a page can't draw over them.
page inpage.js (MAIN) content.js (ISOLATED) background.js (SW) approval window
│ request({method}) ──▶ postMessage(target: ──▶ port.postMessage ──▶ origin = port.sender.origin
│ 'myhelix-contentscript') permission? unlocked?
│ chrome.windows.create(popup) ──▶ shows origin + scope
│ ◀── approve / reject / close
│ vault op (packages/core)
│ Promise resolves ◀── postMessage(target: ◀── port.onMessage ◀── { type:'response', id, result | error }
│ 'myhelix-inpage')
│ on('lock') ◀── event ◀── event ◀── chrome.alarms / chrome.idle -> lock()extension/
├── manifest.json MV3: service worker, MAIN-world provider script, isolated bridge, popup
├── inpage.js window.myhelix + myhelix:requestProvider / myhelix:announceProvider
├── content.js window.postMessage <-> chrome.runtime.connect Port
├── background.js vault session · per-origin permissions · RPC dispatch · approvals · auto-lock
├── popup.html/.js create / unlock / lock · connected sites · Open MyHelix
├── approval.html/.js chrome.windows.create({ type: 'popup' }) approval window
├── popup.css
└── README.md load unpacked · what works · what remainsThe skeleton in extension/ loads unpacked in Chrome 111+. It creates a real vault (PBKDF2, 600,000 iterations, wrapping a random MK, with P-256 identity keys under MK), connects sites, signs messages and auto-locks. It returns public keys and announces discovery in the same format as app.html. Grants, record import and verification go through the approval flow and then return 4200, with TODOs pointing at packages/core.
The same core, in the Secure Enclave
The mobile app is React Native with Expo. It runs the same TypeScript packages/core, with a native module that provides a Web Crypto-compatible subtle, so the crypto code and test vectors are identical on every surface.
| Concern | iOS | Android |
|---|---|---|
| Key custody | A Secure Enclave P-256 key, non-exportable, wraps MK. Keychain access control is WhenUnlockedThisDeviceOnly. | An Android Keystore key backed by StrongBox where available (TEE otherwise), with setUserAuthenticationRequired |
| Unlock | Face ID / Touch ID through LocalAuthentication, bound to the key's access control | BiometricPrompt with a CryptoObject |
| Passkeys | iCloud Keychain passkeys with PRF (iOS 18+). iOS doesn't yet pass PRF to external security keys. | Credential Manager passkeys (PRF where supported) |
| Health import | HealthKit (read-only, per-type consent) → FHIR Observations | Health Connect (read-only, per-type consent) → FHIR Observations |
| Background | Auto-lock when the app goes to the background. Screen content hidden in the app switcher. | FLAG_SECURE on sensitive screens. Auto-lock on pause. |
Pairing with desktop sites
Patients without the extension can approve from their phone, using the same pattern as WalletConnect:
- The site shows a QR code for a
myhelix://pairURI. It contains a random topic, a relay URL and a one-time symmetric key. On a phone, a deep link opens the app directly. - The app scans it and shows the site's domain as the pairing request claims it. Claimed metadata can be spoofed, so production checks it against a signed directory entry and warns about unknown domains before the patient connects.
- Provider RPC flows over the relay, encrypted end to end with the session key.
mh_requestAccessshows the normal approval sheet on the phone. - Sessions expire and can be ended from either side. The relay only sees ciphertext and timing.
Two small contracts
Both contracts are deliberately minimal and self-contained, with no external imports. They store 32-byte values and timestamps. Solidity ^0.8.24, NatSpec documented. Reference code, not audited.
anchor(bytes32 root, uint32 leafCount, bytes32 batchId), callable only by authorized anchorers. It exposes roots(root) → timestamp and emits Anchored. The pure verifyProof(leafHash, proof, isLeft, root) uses the same 0x00/0x01 domain separation as the wallet. verifyAnchored also returns the timestamp. The owner uses two-step transfer.
grant(grantId, grantee, receiptHash, expiresAt) records msg.sender (the patient's smart account) as grantor. revoke(grantId) is grantor-only. The views are isActive(grantId), getGrant and matchesReceipt, and it emits Granted and Revoked.
verifyProof takes the item hash (for example SHA-256(ct)) and applies the 0x00 leaf prefix itself. isLeft[i] is true when proof[i] is the left sibling, which matches position: 'L' in the wallet's proofs.
grantee is a bytes32 commitment, such as SHA-256(granteeDID ‖ nonce), not a clinic address. That way the public chain doesn't reveal which clinic a patient shares with.
HelixAnchor.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title HelixAnchor
/// @author Binary Helix
/// @notice Append-only registry of Merkle roots for the MyHelix wallet. Each root commits to a batch of
/// 32-byte hashes (SHA-256 of record ciphertext, consent-receipt hashes, audit-log heads).
/// No health data, identifiers or ciphertext is ever written here - only 32-byte roots.
/// @dev Merkle construction (must match packages/core):
/// leaf = sha256(0x00 || itemHash)
/// node = sha256(0x01 || left || right)
/// An odd node at the end of a level is promoted unchanged to the next level, so a proof simply has
/// no entry for that level. Domain separation (0x00 / 0x01) prevents second-preimage attacks where an
/// inner node is presented as a leaf.
/// Reference implementation: not audited. Do not deploy to mainnet before an independent audit.
contract HelixAnchor {
/// @notice Batch metadata stored per anchored root.
struct Batch {
bytes32 batchId;
uint32 leafCount;
uint64 timestamp;
uint64 blockNumber;
}
/// @notice Contract owner; manages anchorer roles. Intended to be a multisig / timelock.
address public owner;
/// @notice Pending owner for two-step ownership transfer.
address public pendingOwner;
/// @notice Accounts allowed to call {anchor} (batcher services or approved smart accounts).
mapping(address => bool) public isAnchorer;
/// @notice Merkle root => block timestamp at which it was anchored (0 if never anchored).
mapping(bytes32 => uint256) public roots;
/// @notice Merkle root => batch metadata.
mapping(bytes32 => Batch) public batches;
/// @notice batchId => root, so a batch id cannot be reused.
mapping(bytes32 => bytes32) public rootOfBatch;
/// @notice Emitted once per anchored batch.
/// @param root Merkle root of the batch.
/// @param batchId Off-chain batch identifier (random 32 bytes; carries no personal information).
/// @param leafCount Number of leaves committed by the root.
/// @param anchorer Account that submitted the batch.
/// @param timestamp Block timestamp.
event Anchored(bytes32 indexed root, bytes32 indexed batchId, uint32 leafCount, address indexed anchorer, uint256 timestamp);
/// @notice Emitted when an anchorer role is granted or revoked.
event AnchorerSet(address indexed account, bool allowed);
/// @notice Emitted when ownership transfer is started.
event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner);
/// @notice Emitted when ownership transfer completes.
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
error NotOwner();
error NotPendingOwner();
error NotAnchorer();
error ZeroAddress();
error EmptyRoot();
error EmptyBatch();
error RootAlreadyAnchored(bytes32 root);
error BatchIdAlreadyUsed(bytes32 batchId);
error LengthMismatch();
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
modifier onlyAnchorer() {
if (!isAnchorer[msg.sender]) revert NotAnchorer();
_;
}
/// @param initialOwner Owner that manages anchorer roles.
/// @param initialAnchorer First authorized anchorer (may be the zero address to add later).
constructor(address initialOwner, address initialAnchorer) {
if (initialOwner == address(0)) revert ZeroAddress();
owner = initialOwner;
emit OwnershipTransferred(address(0), initialOwner);
if (initialAnchorer != address(0)) {
isAnchorer[initialAnchorer] = true;
emit AnchorerSet(initialAnchorer, true);
}
}
// ------------------------------------------------------------------------------------------------
// Anchoring
// ------------------------------------------------------------------------------------------------
/// @notice Anchor a Merkle root committing to `leafCount` hashes.
/// @param root Merkle root (see contract-level docs for the hashing scheme).
/// @param leafCount Number of leaves in the batch (> 0).
/// @param batchId Random off-chain batch identifier.
function anchor(bytes32 root, uint32 leafCount, bytes32 batchId) external onlyAnchorer {
if (root == bytes32(0)) revert EmptyRoot();
if (leafCount == 0) revert EmptyBatch();
if (roots[root] != 0) revert RootAlreadyAnchored(root);
if (rootOfBatch[batchId] != bytes32(0)) revert BatchIdAlreadyUsed(batchId);
roots[root] = block.timestamp;
rootOfBatch[batchId] = root;
batches[root] = Batch({
batchId: batchId,
leafCount: leafCount,
timestamp: uint64(block.timestamp),
blockNumber: uint64(block.number)
});
emit Anchored(root, batchId, leafCount, msg.sender, block.timestamp);
}
/// @notice True if `root` has been anchored.
function isAnchored(bytes32 root) external view returns (bool) {
return roots[root] != 0;
}
// ------------------------------------------------------------------------------------------------
// Verification
// ------------------------------------------------------------------------------------------------
/// @notice Recompute a Merkle root from an item hash and its inclusion proof and compare with `root`.
/// @dev Pure: does not check that `root` is anchored - use {verifyAnchored} for that.
/// @param leafHash The 32-byte item hash, e.g. SHA-256(record ciphertext). The 0x00 leaf prefix is applied here.
/// @param proof Sibling hashes from the leaf level upwards (levels where the node was promoted are omitted).
/// @param isLeft isLeft[i] is true when proof[i] is the LEFT sibling (wallet proof position 'L').
/// @param root Expected Merkle root.
/// @return valid True if the folded proof equals `root`.
function verifyProof(bytes32 leafHash, bytes32[] calldata proof, bool[] calldata isLeft, bytes32 root)
public
pure
returns (bool valid)
{
if (proof.length != isLeft.length) revert LengthMismatch();
bytes32 computed = sha256(abi.encodePacked(bytes1(0x00), leafHash));
for (uint256 i = 0; i < proof.length; ++i) {
computed = isLeft[i]
? sha256(abi.encodePacked(bytes1(0x01), proof[i], computed))
: sha256(abi.encodePacked(bytes1(0x01), computed, proof[i]));
}
return computed == root;
}
/// @notice Verify an inclusion proof AND that the root is anchored.
/// @return valid True if the proof folds to `root`.
/// @return anchoredAt Block timestamp of the root (0 if not anchored).
function verifyAnchored(bytes32 leafHash, bytes32[] calldata proof, bool[] calldata isLeft, bytes32 root)
external
view
returns (bool valid, uint256 anchoredAt)
{
valid = verifyProof(leafHash, proof, isLeft, root);
anchoredAt = roots[root];
}
// ------------------------------------------------------------------------------------------------
// Roles & ownership
// ------------------------------------------------------------------------------------------------
/// @notice Grant or revoke the anchorer role.
function setAnchorer(address account, bool allowed) external onlyOwner {
if (account == address(0)) revert ZeroAddress();
isAnchorer[account] = allowed;
emit AnchorerSet(account, allowed);
}
/// @notice Start a two-step ownership transfer.
function transferOwnership(address newOwner) external onlyOwner {
if (newOwner == address(0)) revert ZeroAddress();
pendingOwner = newOwner;
emit OwnershipTransferStarted(owner, newOwner);
}
/// @notice Accept a pending ownership transfer.
function acceptOwnership() external {
if (msg.sender != pendingOwner) revert NotPendingOwner();
emit OwnershipTransferred(owner, msg.sender);
owner = msg.sender;
pendingOwner = address(0);
}
}
ConsentRegistry.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title ConsentRegistry
/// @author Binary Helix
/// @notice Public, tamper-evident record that a MyHelix consent grant existed, when it expires and whether the
/// patient revoked it. Only hashes and timestamps are stored - never health data, scopes, names or DIDs.
/// @dev The grantor is `msg.sender`: the patient's ERC-4337 smart account (passkey-owned, P-256 signatures
/// verified via the RIP-7212 precompile, gas sponsored by a paymaster). The signed receipt itself
/// ({type:"MyHelixConsent", grantId, grantor, grantee, scopeHash, purpose, issuedAt, expiresAt, nonce})
/// stays off-chain; `receiptHash` = SHA-256(canonical JSON of the receipt).
/// `grantee` is an opaque 32-byte commitment to the recipient, e.g. SHA-256(granteeDID || receipt nonce),
/// so the chain does not publicly reveal which clinic a patient shares with.
/// Revocation stops future access by the wallet/relay; it cannot claw back data already viewed or saved.
/// Reference implementation: not audited.
contract ConsentRegistry {
/// @notice On-chain view of a grant.
struct Grant {
address grantor;
uint64 issuedAt;
uint64 expiresAt;
uint64 revokedAt;
bytes32 grantee;
bytes32 receiptHash;
}
/// @notice grantId => grant. grantId is random 32 bytes chosen by the wallet.
mapping(bytes32 => Grant) private _grants;
/// @notice Emitted when a grant is registered.
event Granted(bytes32 indexed grantId, address indexed grantor, bytes32 grantee, bytes32 receiptHash, uint64 expiresAt);
/// @notice Emitted when a grant is revoked by its grantor.
event Revoked(bytes32 indexed grantId, address indexed grantor, uint64 revokedAt);
error GrantExists(bytes32 grantId);
error UnknownGrant(bytes32 grantId);
error NotGrantor();
error AlreadyRevoked(bytes32 grantId);
error InvalidExpiry();
error EmptyReceipt();
/// @notice Register a consent grant.
/// @param grantId Random 32-byte grant identifier (matches the receipt's grantId).
/// @param grantee Opaque 32-byte commitment to the grantee (see contract docs).
/// @param receiptHash SHA-256 of the canonical signed receipt.
/// @param expiresAt Unix time (seconds) after which the grant is inactive. Must be in the future.
function grant(bytes32 grantId, bytes32 grantee, bytes32 receiptHash, uint64 expiresAt) external {
if (_grants[grantId].grantor != address(0)) revert GrantExists(grantId);
if (receiptHash == bytes32(0)) revert EmptyReceipt();
if (expiresAt <= block.timestamp) revert InvalidExpiry();
_grants[grantId] = Grant({
grantor: msg.sender,
issuedAt: uint64(block.timestamp),
expiresAt: expiresAt,
revokedAt: 0,
grantee: grantee,
receiptHash: receiptHash
});
emit Granted(grantId, msg.sender, grantee, receiptHash, expiresAt);
}
/// @notice Revoke a grant. Only the original grantor (smart account) can revoke.
/// @dev Revoking an already-expired grant is allowed, so the audit trail can show an explicit revocation.
function revoke(bytes32 grantId) external {
Grant storage g = _grants[grantId];
if (g.grantor == address(0)) revert UnknownGrant(grantId);
if (g.grantor != msg.sender) revert NotGrantor();
if (g.revokedAt != 0) revert AlreadyRevoked(grantId);
g.revokedAt = uint64(block.timestamp);
emit Revoked(grantId, msg.sender, g.revokedAt);
}
/// @notice True if the grant exists, is not revoked and has not expired.
function isActive(bytes32 grantId) external view returns (bool) {
Grant storage g = _grants[grantId];
return g.grantor != address(0) && g.revokedAt == 0 && block.timestamp < g.expiresAt;
}
/// @notice Full on-chain grant record (zeroed struct if unknown).
function getGrant(bytes32 grantId) external view returns (Grant memory) {
return _grants[grantId];
}
/// @notice Check that an off-chain receipt matches what was registered.
/// @param grantId Grant identifier.
/// @param receiptHash SHA-256 of the canonical receipt presented off-chain.
function matchesReceipt(bytes32 grantId, bytes32 receiptHash) external view returns (bool) {
Grant storage g = _grants[grantId];
return g.grantor != address(0) && g.receiptHash == receiptHash;
}
}
forge init contracts && cp HelixAnchor.sol ConsentRegistry.sol contracts/src/
cd contracts
forge build # solc 0.8.24+
forge test -vvv # include a test that folds proofs produced by packages/core
forge script script/Deploy.s.sol --rpc-url base_sepolia --broadcast --verifyAn interaction checker that never phones home
The Care section tracks everything a member takes: prescriptions, OTC products, Binary Helix supplements from js/products.js, the HelixCare Rx protocols and the peptide library. It checks all of it on the device. The medication list, dose log, injection sites, vials, conditions and allergies are stored under sealed.care, inside the same AES-256-GCM sealed index as everything else. Every change goes into the hash-chained audit log, so a member's medication history is tamper-evident without ever reaching the chain.
| Check | Inputs | Example |
|---|---|---|
| Drug–drug / drug–supplement | Tag-pair rules across every active item | Vitamin K2 (D3 + K2) with warfarin; omeprazole with clopidogrel; binder 2 h apart from everything |
| Pharmacogenomics | PGx genotypes from the member's genome record (CPIC) | SLCO1B1 decreased function: simvastatin flagged, rosuvastatin ≤ 20 mg marked compatible |
| Conditions & allergies | Member-entered safety profile | GLP-1 with MTC / MEN 2 history; growth-promoting peptides with active cancer; penicillin allergy |
| Labs | Latest decrypted biomarkers | Metformin vs eGFR; ferritin reached target while on iron; IGF-1 above range on a GH protocol |
| Test preparation | Binary Helix tests and kits in progress | Biotin before immunoassays; creatine vs creatinine/eGFR; antibiotics before a gut test; acetaminophen and some CGMs |
| Totals & doses | Nutrients per product; monograph dose frameworks | Vitamin D or zinc above the NIH upper limit across products; peptide dose above the monograph range; GLP-1 titration steps |
Each finding carries a severity (avoid · major · moderate · minor · separate doses · test prep · info · compatible) and an evidence label (prescribing label, CPIC, clinical guidance, literature, Binary Helix monograph, mechanism-only). Members can mark a finding as "reviewed with my clinician", and that is also written to the audit log. The demo rule set in js/app/rxdata.js is curated but not exhaustive. Production should license a maintained interaction database and ship it to the device, so that checking a list never needs a server.
Peptide protocols
Protocols come from the site's templates, for example Mitochondrial Renewal, Recovery, GLP-1 label titration, the TRIIM variant and the hormone protocols. Each template creates linked items with a cycle (weeks on and washout) and labs to monitor. The reconstitution calculator uses the U-100 relationship: 100 units = 1 mL. It warns about doses too small to measure, doses that exceed the syringe, and rounding error at the syringe's tick marks. Vials track concentration, remaining milligrams (from logged doses) and a use-by date. Injection sites rotate by least-recent use across subcutaneous and intramuscular sites.
Sharing and e-prescriptions
Grants can include two new kinds: medications (the list, allergies and the member's open safety flags) and protocols (protocols plus a 90-day dose log with sites and reactions). A clinic can send an e-prescription with mh_submitRecord using kind: "prescription". When the member accepts it, the wallet stores the signed record, adds the medicine to the list and re-runs the safety check on the device.
// payload is ECIES-encrypted to mh_getPublicKeys().encryption before sending
await myhelix.request({ method: 'mh_submitRecord', params: {
envelope, // { epk, iv, ct } of the JSON below
source: 'HelixCare Clinics',
signature, signerPublicKey // ECDSA P-256 over canonical(envelope)
}});
// plaintext inside the envelope
{ "kind": "prescription", "date": "2026-09-21", "label": "e-Prescription · Ezetimibe 10 mg",
"payload": { "catId": "ezetimibe", "name": "Ezetimibe", "dose": 10, "unit": "mg", "route": "oral",
"sched": { "type": "daily", "times": ["08:00"] }, "prescriber": "Dr. Amara Okafor, MD", "refills": 3 } }Speak the language of health IT
Encryption is only half of portability. The other half is data that every EHR, lab and research tool already understands.
| Standard | Used for | In MyHelix |
|---|---|---|
| HL7 FHIR R4 | Record structure | The demo exports the whole vault as a FHIR R4 Bundle (Patient, Observation, DiagnosticReport, DocumentReference) from Settings. Production payloads are FHIR natively, and genomics uses MolecularSequence and the Genomics Reporting IG. |
| LOINC | What was measured | Every biomarker has a LOINC code (for example 13457-7 LDL-C, 4548-4 HbA1c). Internal BH- codes are used only where no LOINC code exists. |
| UCUM | Units | Values are normalised to UCUM units on import (for example mg/dL, mmol/L, nmol/L), so trends across labs are comparable. |
| SMART on FHIR | Hospital portal import | OAuth 2.0 patient-standalone launch to pull records from patient portals. Tokens are used on the device and then discarded. |
| W3C DID Core | Identity | Patients use did:key (P-256). Clinicians and labs use DIDs published in the directory. |
| W3C Verifiable Credentials | Clinician and lab identity | Licence and accreditation credentials that the approval UI checks before showing a verified badge |
| CPIC guidelines | Pharmacogenomics | Genotype-to-phenotype and drug recommendations for PGx insights, shown with evidence levels for clinician review |
What we defend against, and what's left
| Threat | Mitigation | Residual risk |
|---|---|---|
| Breach of relay or storage | Only ciphertext is stored, with a separate DEK per record. All metadata is in the sealed index. Keys never leave devices. | Blob sizes, timing and record counts. Mitigated with padding buckets (production). |
| Offline brute force of the wrapped MK | PBKDF2 with 600,000 iterations, a strength meter, passkey unlock preferred | A weak passphrase against a well-funded attacker who has the vault file |
| Stolen or unlocked device | Auto-lock, OS screen-lock hook, biometrics, non-extractable keys where the platform allows | Data visible during an unlocked session |
| Phishing site requests access | Per-origin permissions. The origin shown comes from the browser. Grantees are resolved by DID against the verified directory, and packages are encrypted only to directory keys. The patient can narrow scope and duration. | A patient who approves a lookalike domain. Mitigated with warnings and a blocklist. |
| Malicious page tampers with the provider | The provider holds no secrets. Approvals render in extension-owned windows. Origin comes from port.sender. | A page can draw a fake approval UI inside itself. Patients are taught to trust only the extension window. |
| Forged lab results | ECIES to the wallet key. An ECDSA signature over the envelope is verified before decrypting. Kit-barcode matching. Production: lab keys tied to Verifiable Credentials. | A compromised lab signing key until it's revoked |
| Clinician keeps data after revocation | Expiry, revocation, signed receipts, audit log, business associate agreements | Data already viewed or saved can't be recalled |
| On-chain correlation | Only Merkle roots and receipt hashes. Salted grantee commitments. Pseudonymous smart accounts. | The timing and number of a single account's consent transactions |
| Anchorer key compromise | Roles are revocable. Anchors are append-only, so existing roots can't be changed. | Junk roots, which are harmless because verification needs a matching proof |
| Colluding guardians | 2-of-3 threshold, shares encrypted to each guardian, guidance to choose independent guardians | Two guardians who collude can rebuild MK |
| Malicious update or supply chain | Signed releases, reproducible builds, pinned dependencies, strict CSP, SRI, audits | A compromised distribution channel or store account |
| Future quantum computers | AES-256 and SHA-256 keep a large margin. A migration path to hybrid ML-KEM for ECIES. | P-256 ECIES traffic captured today could be decrypted later (“harvest now, decrypt later”) |
Compliance notes
Where Binary Helix or HelixCare Clinics act as a covered entity or business associate, PHI is handled under BAAs with partner labs, with access logging and minimum-necessary scopes. The patient-held wallet is designed so our servers never hold readable PHI.
Only 32-byte Merkle roots, receipt hashes, salted commitments and timestamps. No names, dates of birth, record contents, scopes or DIDs.
Erasure means crypto-shredding: delete the ciphertext on the relay and the wrapped keys on every device. What remains on chain is a hash of random-IV ciphertext that no longer exists. It can't be linked back to a person or reversed to content, so it isn't personal data we can link.
Health and genetic data need explicit, specific consent under GDPR Article 9 and US state laws such as Washington's My Health My Data Act. Every grant records its purpose and expiry in a signed receipt.
These notes describe the design intent. They aren't legal advice, and counsel will review them for each market before launch.
From demo to production
-
Phase 1
Core package + web app
packages/corein TypeScript, with published test vectors- PWA: vault, imports, biomarkers, grants, audit log
- Relay service (ciphertext + mailboxes) and HELX devnet
- Passphrase, BIP-39 and Shamir recovery
Exit: external cryptography review of
packages/core -
Phase 2
Browser extension
- MV3 extension on the shared UI bundle
window.myhelix+ discovery, approval windows- HelixCare Clinics portal integration, lab
mh_submitRecord - Passkey (PRF) unlock
Exit: Chrome Web Store and Edge Add-ons listing, pen test
-
Phase 3
Mobile app
- Expo app with Secure Enclave / StrongBox custody
- HealthKit and Health Connect import
- QR / deep-link pairing for desktop sites
- Anonymous kit scanning
Exit: App Store and Play review, mobile security assessment
-
Phase 4
Mainnet anchoring & audits
- HelixAnchor + ConsentRegistry on Base, with an optional mirror chain
- ERC-4337 smart accounts, P-256 owners, paymaster policy
- Smart-contract audit, SOC 2 Type II, bug bounty
- Synthetic Insight enclave grants with attestation
Exit: public verification tooling and a transparency report
Suggested monorepo layout
myhelix/
├── packages/
│ ├── core/ TypeScript, zero UI. One implementation for every surface.
│ │ ├── src/crypto/ aes-gcm · kdf (PBKDF2/HKDF) · ecies · bip39 · shamir · did · canonical-json
│ │ ├── src/vault/ master key, wrapping, unlock factors, storage adapters (IndexedDB / chrome.storage / Keychain)
│ │ ├── src/records/ envelopes, FHIR normalisation, import pipelines
│ │ ├── src/grants/ scopes, packages, receipts, revocation
│ │ ├── src/ledger/ Merkle batching, proofs, HelixAnchor / ConsentRegistry client, ERC-4337 UserOps
│ │ ├── src/provider/ RPC schema, errors, permission model (shared by extension + mobile pairing)
│ │ └── test/vectors/ JSON test vectors: BIP-39, HKDF, ECIES, Shamir, Merkle, receipts
│ └── ui/ React components + shell layouts (web / extension / mobile-web)
├── apps/
│ ├── web/ PWA (app.html in this demo)
│ ├── extension/ MV3 (extension/ skeleton in this demo)
│ └── mobile/ Expo / React Native
├── contracts/ Foundry: src/HelixAnchor.sol, src/ConsentRegistry.sol, test/, script/
└── services/ suggested: relay (ciphertext + grant mailboxes), batcher, paymaster policy