MyHelix Developer guide · Spec v1

One wallet core. Three surfaces. No plaintext on our servers.

MyHelix is a patient-held, end-to-end encrypted health data wallet. It works like MetaMask, but for health records. Clinics, labs and research portals call an injected provider, and the patient approves every access. Health data never touches a blockchain. Only hashes do.

Web Crypto only P-256 · AES-GCM-256 chainId 0x48454c58 No token
01 · Overview

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.

Extension layout

Web app

Installable PWA. Records, biomarkers, DNA, insights, sharing and a ledger explorer.

Web app demo

Mobile app

React Native and Expo. Keys in the Secure Enclave or StrongBox, HealthKit and Health Connect import, QR pairing.

Mobile layout

Design 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.

02 · Try it

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:

  1. 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.
  2. 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.
  3. 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.
  4. Connected sites. The Connected sites simulator calls every mh_* method as a clinic portal would, through the same approval sheets.
  5. 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.
  6. Ledger. Ledger → Verify chain recomputes every block hash, parent link and transaction root.
  7. Activity. Activity → Verify log re-walks the hash-chained audit log from the genesis hash.
  8. 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.

DevTools console on app.html
// 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));
03 · Architecture

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.

MyHelix system architecture The patient device runs the wallet core with three surfaces: extension, web app and mobile. It exchanges provider RPC calls with clinician and lab sites after user approval, syncs ciphertext and grant packages with the encrypted relay, sends Merkle roots and consent receipt hashes to the Helix Anchor chain on Base through sponsored user operations, and shares scoped grant packages with the Synthetic Insight attested enclave, which returns encrypted insights. Patient device KEYS · PLAINTEXT · APPROVALS Extension Web app Mobile Wallet core packages/core VaultMK · unlock factors IdentityECDSA · ECDH · did:key RecordsDEK envelopes · FHIR Grantsscope · ECIES · receipts Ledger clientMerkle · UserOps Audit loghash-chained Provider RPCwindow.myhelix · permissions · approval UI Secure key storage Ciphertext vault blob (localStorage in demo) chrome.storage.session (extension session) Secure Enclave / StrongBox · passkeys Clinician & lab sites HelixCare Clinics portal · partner labs mh_requestAccess · mh_submitRecord own ECDH key · verifies receipts Encrypted relay & storage Holds no keys. Cannot decrypt. record ciphertext · grant packages kit mailboxes · encrypted backups Helix Anchor chain Base L2 in production · HELX devnet in demo HelixAnchor · roots(root) → timestamp ConsentRegistry · grant / revoke EntryPoint · paymaster · P-256 (RIP-7212) Synthetic Insight enclave Attested TEE. Models come to the data. grantee like any clinician · no retention provider RPC + approval ECIES lab results in ciphertext only sync · packages · backups Merkle roots · receipt hashes sponsored UserOps, no gas scoped grant package encrypted insights back fetch by grantId
Only the patient device, and a grantee holding a scoped grant key, can decrypt. The relay and the chain never can.Scroll sideways
Patient device

The only place where MK, private keys and plaintext exist. Locks automatically.

Encrypted relay

Content-addressed ciphertext store and mailboxes. It enforces grant expiry and deletes packages when a grant is revoked.

Helix Anchor chain

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.

Synthetic Insight enclave

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.

04 · Key hierarchy

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.

MyHelix key hierarchy Four unlock paths lead to the Vault Master Key. A passphrase goes through PBKDF2-SHA256 with 600,000 iterations to KEK_pass. A passkey goes through the WebAuthn PRF extension and HKDF to KEK_passkey. A BIP-39 phrase goes through PBKDF2-SHA512 with 2048 iterations and HKDF to KEK_recovery. Guardian shares rebuild the key with 2-of-3 Shamir over GF(2^8). The master key wraps one data key per record, plus the ECDSA signing key and the ECDH key-agreement key. A fresh grant key per share is wrapped to the recipient with ECIES. UNLOCK FACTORS (EACH WRAPS MK) Passphrase PBKDF2-HMAC-SHA256 600,000 iters · 16-byte salt → KEK_pass Passkey IF SUPPORTED WebAuthn PRF output → HKDF "myhelix/passkey/v1" → KEK_passkey BIP-39 phrase · 12 words PBKDF2-SHA512 · 2048 iters → HKDF "myhelix/recovery/v1" → KEK_recovery Guardians · 2 of 3 Shamir over GF(2^8), 0x11b shares: mhs1-<x>-<hex> → rebuilds MK itself AES-GCM unwrap Vault Master Key (MK) 256-bit random · AES-GCM · device only wraps (AES-GCM / PKCS#8) Record DEKs 1 per record · AES-GCM-256 stored as envelope.wdek Grant keys fresh per grant · AES-GCM ECIES-wrapped to grantee Signing key ECDSA P-256 · SHA-256 PKCS#8 under MK Key agreement ECDH P-256 PKCS#8 under MK protects record ciphertexthash = SHA-256(ct) → Merkle leaf protects one shared packagescope · purpose · expiry did:key · account addressreceipts · UserOps · audit heads receives lab results (ECIES)and incoming grant packages
Every arrow is an AES-GCM wrap or a standard KDF. There are no custom primitives.Scroll sideways
Every key in MyHelix, its algorithm, where it is stored and what it protects
KeyAlgorithmWhere it livesProtects
Vault Master Key (MK)256-bit random, AES-GCMMemory while unlocked. At rest only as wrapped copies (wrap.pass, wrap.recovery, wrap.passkeys[]).Record DEKs, identity private keys and the sealed index
KEK_passPBKDF2-HMAC-SHA256, 16-byte salt, 600,000 iterations → AES-GCM-256Derived at unlock, never storedWrapped MK (passphrase copy)
KEK_passkeyWebAuthn PRF output (per-passkey salt) → HKDF-SHA256 (salt "myhelix", info "myhelix/passkey/v1") → AES-GCM-256Derived during the passkey ceremony, never stored. Demo: where the browser supports PRF. On mobile: Secure Enclave / StrongBox.Wrapped MK (passkey copy)
Recovery seedBIP-39: 128-bit entropy + 4-bit SHA-256 checksum → 12 words; seed = PBKDF2-HMAC-SHA512(NFKD(mnemonic), "mnemonic", 2048) → 64 bytesOn 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_recoveryHKDF-SHA256(seed, salt "myhelix", info "myhelix/recovery/v1") → AES-GCM-256Derived during recovery, never storedWrapped MK (recovery copy)
Guardian sharesShamir 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 keyECDSA P-256, SHA-256PKCS#8 encrypted under MK (identity.signPriv)Consent receipts, anchor transactions / UserOps, audit-log heads, mh_signMessage. Derives the DID and account address.
Key-agreement keyECDH P-256PKCS#8 encrypted under MK (identity.encPriv)Inbound lab results and grant packages (ECIES recipient)
Record DEK256-bit random, AES-GCM, 12-byte IVWrapped by MK inside the envelope (wdek)One record's payload
Grant key256-bit random, AES-GCMECIES-wrapped to the grantee (epk, wiv, wct)One filtered share package
ECIES ephemeral keyECDH P-256 → HKDF-SHA256 (salt "myhelix", info "myhelix/ecies/v1") → AES-GCM-256Discarded 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 writevault.sealed, rewritten on every changeAll 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 and address derivation
// 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
KEK derivation (passphrase and recovery phrase)
// 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']);
05 · Record encryption

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.

A record before sealing (never leaves the device unencrypted)
{
  "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
        ]
      }
    ]
  }
}
Sealing a record (mirrors js/app/vault.js)
// 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
}
Stored envelope, v1
{
  "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
  }
}
Envelope fields
FieldMeaning
id, vRandom record id (rec_ + 12 hex) and envelope version (1)
iv, ct12-byte AES-GCM IV and ciphertext of the payload (base64). The GCM tag is appended to ct.
wdekThe DEK encrypted under MK: {iv, ct}
hashSHA-256 of the ciphertext bytes, as 0x hex. Proves integrity and reveals nothing, because the IV and DEK are random.
size, createdAtLength of the base64 ciphertext, and creation time in milliseconds
anchornull 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.

Sealed index plaintext (abridged; stored only as ciphertext)
{
  "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": []
}
The whole vault blob, as a server or backup file would hold it
{
  "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.

06 · Recovery

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.

Recovery methods and trade-offs
MethodHow it worksGood atHonest trade-off
PassphrasePBKDF2-HMAC-SHA256, 600,000 iterations → KEK_passWorks everywhere, including the demoA 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 phrase12 words (128-bit entropy) → seed → HKDF → KEK_recovery, which unwraps wrap.recoverySurvives losing every device, as long as an encrypted vault copy existsUseless 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-3MK 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.
Passkey unlock with the PRF extension
// 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);
Social recovery shares
// 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.

07 · Receiving results

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.

  1. 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.
  2. 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.
  3. 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.
  4. Sign. The lab signs canonical(envelope) with its ECDSA P-256 key.
  5. Deliver. The lab either posts the envelope and signature to the relay inbox for that kit, or calls mh_submitRecord while the patient has the lab portal open.
  6. 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.
ECIES + signature for lab integrations (mirrors js/app/crypto.js)
// 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.

09 · Blockchain anchoring

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.

Domain-separated Merkle tree with an odd node promoted Five item hashes become leaves as SHA-256 of 0x00 followed by the hash. Leaves 0 and 1, and leaves 2 and 3, are combined as SHA-256 of 0x01, left and right. Leaf 4 has no pair, so it is promoted unchanged up two levels. The root combines node 0-3 with leaf 4. The proof for leaf 2 is leaf 3 on the right, node 0-1 on the left, and leaf 4 on the right. root node 0-3 leaf 4 · proof R node 0-1 · proof L node 2-3 leaf 4 ↑ leaf 0 leaf 1 leaf 2 ★ leaf 3 · R leaf 4 h0h1h2 = SHA-256(ct)h3h4 leaf = SHA-256(0x00 ‖ h) node = SHA-256(0x01 ‖ left ‖ right) odd node promoted unchanged PROOF FOR LEAF 2 = [leaf 3 (R), node 0-1 (L), leaf 4 (R)]
The 0x00 and 0x01 prefixes stop an inner node from being passed off as a leaf (a second-preimage attack). A promoted node adds no step to the proof.Scroll sideways

Batching and verification

  1. 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.
  2. One call, HelixAnchor.anchor(bytes32 root, uint32 leafCount, bytes32 batchId), anchors the batch. The cost is the same for 1 leaf or 100,000.
  3. 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.
  4. 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.
Build, anchor and verify (mirrors js/app/crypto.js and vault.js)
// 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 ConsentRegistry and HelixAnchor calls, with rate limits.
Sponsored UserOperation (sketch, viem-style)
// 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.

10 · Provider API

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

mh_requestAccountsconnect prompt

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]
mh_accounts

The connected DID, or an empty array if not connected or locked. Never prompts.

params
none
returns
[did] | []
mh_chainId

The anchoring network. The demo returns Helix Anchor Devnet.

params
none
returns
"0x48454c58"
mh_getPublicKeysconnected

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}}
mh_requestAccessapproval UI

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}
mh_revokeAccessconnected

Revoke a grant (for example, when a clinic closes a care episode). Also emits grantRevoked.

params
{grantId}
returns
{txHash}
mh_submitRecordapproval UI

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}
mh_signMessageapproval UI

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}
mh_verifyRecordconnected

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}
TypeScript definitions
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

Provider error codes
CodeNameWhenWhat your site should do
4001User rejectedThe patient declined, or closed the approval windowSay so politely. Don't retry in a loop.
4100UnauthorizedThe origin isn't connected, the grantee isn't in the verified directory, or the grant is unknownCall mh_requestAccounts first, and register your clinic in the directory
4200Unsupported methodUnknown method, or not available on this surfaceFeature-detect and degrade gracefully
4900LockedThe wallet is locked and the patient dismissed the unlock promptAsk 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

Provider events
EventPayloadFired 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 / unlocknoneManual 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.

EIP-6963-style discovery
// 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
// 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 that matchesReceipt(grantId, receiptHash) is true.
  • Handle PHI as PHI. Store decrypted data only in your EHR under your HIPAA obligations, and record the grantId next to them.
11 · Browser extension

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 world

Defines window.myhelix and announces it. It only sends messages. It holds no state worth stealing.

content.js, the isolated world

Bridges window.postMessage to a chrome.runtime Port, filtering on source === window and a target tag.

background.js, the service worker

Holds 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.

Popup and approval windows

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.

Request lifecycle
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()
Skeleton in this repository
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 remains

The 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.

12 · Mobile app

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.

Mobile platform integration
ConcerniOSAndroid
Key custodyA 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
UnlockFace ID / Touch ID through LocalAuthentication, bound to the key's access controlBiometricPrompt with a CryptoObject
PasskeysiCloud Keychain passkeys with PRF (iOS 18+). iOS doesn't yet pass PRF to external security keys.Credential Manager passkeys (PRF where supported)
Health importHealthKit (read-only, per-type consent) → FHIR ObservationsHealth Connect (read-only, per-type consent) → FHIR Observations
BackgroundAuto-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:

  1. The site shows a QR code for a myhelix://pair URI. It contains a random topic, a relay URL and a one-time symmetric key. On a phone, a deep link opens the app directly.
  2. 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.
  3. Provider RPC flows over the relay, encrypted end to end with the session key. mh_requestAccess shows the normal approval sheet on the phone.
  4. Sessions expire and can be ended from either side. The relay only sees ciphertext and timing.
13 · Smart contracts

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.

HelixAnchor

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.

contracts/HelixAnchor.sol

ConsentRegistry

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.

contracts/ConsentRegistry.sol

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
contracts/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
contracts/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;
    }
}
Build, test and deploy with Foundry
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 --verify
14 · Medication & protocol safety

An 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.

What the on-device safety engine checks
CheckInputsExample
Drug–drug / drug–supplementTag-pair rules across every active itemVitamin K2 (D3 + K2) with warfarin; omeprazole with clopidogrel; binder 2 h apart from everything
PharmacogenomicsPGx genotypes from the member's genome record (CPIC)SLCO1B1 decreased function: simvastatin flagged, rosuvastatin ≤ 20 mg marked compatible
Conditions & allergiesMember-entered safety profileGLP-1 with MTC / MEN 2 history; growth-promoting peptides with active cancer; penicillin allergy
LabsLatest decrypted biomarkersMetformin vs eGFR; ferritin reached target while on iron; IGF-1 above range on a GH protocol
Test preparationBinary Helix tests and kits in progressBiotin before immunoassays; creatine vs creatinine/eGFR; antibiotics before a gut test; acetaminophen and some CGMs
Totals & dosesNutrients per product; monograph dose frameworksVitamin 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.

Clinic portal: send an e-prescription
// 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 } }
15 · Data standards

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.

Standards used by MyHelix
StandardUsed forIn MyHelix
HL7 FHIR R4Record structureThe 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.
LOINCWhat was measuredEvery 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.
UCUMUnitsValues are normalised to UCUM units on import (for example mg/dL, mmol/L, nmol/L), so trends across labs are comparable.
SMART on FHIRHospital portal importOAuth 2.0 patient-standalone launch to pull records from patient portals. Tokens are used on the device and then discarded.
W3C DID CoreIdentityPatients use did:key (P-256). Clinicians and labs use DIDs published in the directory.
W3C Verifiable CredentialsClinician and lab identityLicence and accreditation credentials that the approval UI checks before showing a verified badge
CPIC guidelinesPharmacogenomicsGenotype-to-phenotype and drug recommendations for PGx insights, shown with evidence levels for clinician review
16 · Threat model & compliance

What we defend against, and what's left

Threat model: threats, mitigations and residual risk
ThreatMitigationResidual risk
Breach of relay or storageOnly 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 MKPBKDF2 with 600,000 iterations, a strength meter, passkey unlock preferredA weak passphrase against a well-funded attacker who has the vault file
Stolen or unlocked deviceAuto-lock, OS screen-lock hook, biometrics, non-extractable keys where the platform allowsData visible during an unlocked session
Phishing site requests accessPer-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 providerThe 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 resultsECIES 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 revocationExpiry, revocation, signed receipts, audit log, business associate agreementsData already viewed or saved can't be recalled
On-chain correlationOnly Merkle roots and receipt hashes. Salted grantee commitments. Pseudonymous smart accounts.The timing and number of a single account's consent transactions
Anchorer key compromiseRoles 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 guardians2-of-3 threshold, shares encrypted to each guardian, guidance to choose independent guardiansTwo guardians who collude can rebuild MK
Malicious update or supply chainSigned releases, reproducible builds, pinned dependencies, strict CSP, SRI, auditsA compromised distribution channel or store account
Future quantum computersAES-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

HIPAA-aligned

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.

No PHI on chain

Only 32-byte Merkle roots, receipt hashes, salted commitments and timestamps. No names, dates of birth, record contents, scopes or DIDs.

GDPR erasure

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.

Consent & special-category data

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.

17 · Build roadmap

From demo to production

  1. Phase 1

    Core package + web app

    • packages/core in 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

  2. 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

  3. 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

  4. 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/ monorepo
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

This guide describes a reference design and a browser demo. The demo uses fictional data and a simulated chain, and must not be used with real health information. The contracts and extension skeleton are unaudited reference code. MyHelix insights are informational and are not intended to diagnose, treat, cure or prevent any disease.