Local clinic integration layer. Bridges an existing EMR to the HUUID
resolver network without moving any medical data off the clinic's own
server. Governing spec: HUUID-EMR-STUB-v0.1.2.docx (v0.1 and v0.1.1 are
retired -- see Section 0 of that document).
This is a standalone Node.js/Express/TypeScript service, not part of
the Next.js resolver (huuid-resolver). It runs locally at each clinic.
Base build + Security Layer 1 (SQLCipher, P1) + Security Layer 2 (OS
keystore, P2) + Security Layer 3 (process integrity hashing, P4) +
Security Layer 4 (QR card offline verification, resolution tier 4). The
local cache DB is AES-256-CBC + HMAC-SHA512 encrypted (SQLCipher's real
cipher -- not GCM, see below), keyed by HKDF-SHA256 over the facility
private key. The facility private key itself lives in the OS credential
store (Windows Credential Manager / macOS Keychain / Linux libsecret via
@napi-rs/keyring) once npm run secure-keys has been run -- the PEM
file is shredded and deleted at that point. A file fallback remains for
the transition period before that script has been run. npm run install-integrity-baseline signs a manifest of every src//scripts/
file (plus package-lock.json) with the facility key; npm run start
verifies it on every startup and every 6 hours thereafter. POST /qr/verify verifies an offline QR card token entirely without network
access, against a resolver public key cached locally by npm run download-keys -- see "QR card verification (tier 4, P4)" below for how
it works and a real, load-bearing honesty note about the test key used to
build and verify it.
npm run diagnostics reports all of the above.
keytar (used for the initial P2 build) is unmaintained upstream -- last
release over a year ago, GitHub repo archived. @napi-rs/keyring is its
actively maintained replacement: verified here with a real Windows prebuild
(@napi-rs/keyring-win32-x64-msvc, N-API so ABI-stable), a full Windows
Credential Manager set/get/delete roundtrip, and its API is synchronous
(unlike keytar's Promise-based one) -- facility-key.ts's exported
functions stay async regardless, purely so every existing caller's
await keeps working unchanged.
Real finding, not a prebuild problem: the two libraries were not
Credential-Manager-interoperable. keytar stored under Windows target
huuid-emr-stub/facility-private-key (service/account); @napi-rs/keyring
stores under facility-private-key.huuid-emr-stub (account.service) --
different strings, same underlying store, mutually invisible. This was
originally a real operational gap: a facility that had already run the
keytar-based secure-keys had its PEM already shredded, leaving its key
orphaned under the old target name after the dependency swap, with no
plaintext backup. This is now fixed automatically -- see "Upgrading from
keytar-based installations" below.
Migration is automatic on first startup after upgrading. No manual steps required.
src/keystore-migration.ts runs as the last-resort step in the same
lookup chain facility-key.ts already used (current keystore -> PEM file
-> then legacy keytar, Windows only) -- so it only ever runs for a
facility that has neither a current-format keystore entry nor a PEM file,
which is exactly the state a keytar-upgraded facility is in (its PEM was
already shredded by the old secure-keys). On every other startup it is
never reached at all.
How it actually reads the legacy credential -- and why not the obvious
way. @napi-rs/keyring ships Entry.withTarget(target, service, account),
which looked like the natural fix: point it at keytar's exact target string
and read the key without any extra dependency. Tested against a real
keytar-written credential, it returned an empty string. Tested again as a
pure self-consistency check -- writing with withTarget and reading back
with withTarget, no keytar involved at all -- it still returned an
empty string. That ruled it out as a bug in the npm package on Windows,
not a target-string mismatch, before it was built on.
The actual mechanism: raw P/Invoke to advapi32.dll's CredRead /
CredDelete via PowerShell's Add-Type -- the same underlying Win32 API
keytar's native addon and @napi-rs/keyring's Rust backend both call
internally, invoked directly rather than through either npm package.
Get-StoredCredential (the other tool named for this) also doesn't work
out of the box -- it isn't a built-in cmdlet, it ships in the third-party
CredentialManager PowerShell module, which isn't installed by default
and installing it wasn't assumed. Add-Type/P/Invoke requires no module
install; it ships with every Windows PowerShell.
One more thing verified rather than assumed: the CredentialBlob decode.
The "correct" Windows-native assumption is UTF-16LE. Decoding a real
keytar-written credential that way produced garbage. keytar's Windows
addon writes the password as raw UTF-8 bytes instead -- confirmed by
switching the decode to UTF-8 and getting a byte-for-byte match against
the known original value. src/keystore-migration.ts decodes as UTF-8
for exactly this reason.
Migration sequence, matching the spec's steps exactly: read the legacy
credential -> write it to the current keystore -> read it back and verify
the value matches -> only then delete the legacy credential. If
verification fails, the legacy credential is left in place and nothing is
deleted -- npm run diagnostics reports this as an error rather than
silently losing the key. Every failure mode (PowerShell unavailable, call
times out, nothing to migrate) returns null and falls through to the
normal "no key found" handling; migration never crashes startup.
npm run diagnostics reports Migrated facility key from legacy keytar store to current keyring store (this run) when it (or npm run start)
is the one that triggers the migration, and includes "keytarMigration": "migrated" in its JSON output. On any later run, once the key is already
in the current keystore, this step is skipped entirely and nothing is
reported.
Scope note: this migration path is Windows-only, matching the target strings actually verified against a real keytar-written credential. macOS Keychain and Linux libsecret were not observed to have the same interoperability gap during this build and are out of scope here.
src/facility-key.ts is the single source of truth: keystore first, PEM
file fallback, clear error if neither has a key. Two implementation details
worth knowing:
- Raw-bytes reconstruction, not PEM round-tripping. The keystore stores
only the raw 32-byte Ed25519 private scalar (base64url). Rebuilding a
usable signing key from just that requires the RFC 8410 PKCS8 DER prefix
for Ed25519 (fixed, 16 bytes, same for every key) rather than Node's JWK
import, which insists on the public key (
x) even though it's mathematically redundant for Ed25519. This also avoids ever creating an intermediate PEM string, which -- being an immutable JS string -- could not be zeroed anyway (see below). - Memory zeroing (Step 7) is real but string-limited. Every raw-bytes
Buffertouching the private key is.fill(0)'d immediately after use (cache-key.ts,facility-key.ts,resolver-client.ts,scripts/secure-keys.ts).resolver-client.tsno longer caches a signing key across requests -- it fetches raw bytes, builds a key, signs, and zeroes on every single call, per this step's literal wording. What cannot be zeroed: the base64url stringEntry.getPassword()returns, or a PEM string, since JS strings are immutable and nothing can wipe their backing memory from JS code. This is a real limit of the runtime, not an oversight -- documented rather than glossed over.
The spec's Step 1 named @journeyapps/sqlcipher. Its own README states
Windows is not supported ("Windows and prebuilt binary publishing are
intentionally unsupported in this phase") -- confirmed by testing, and a
real blocker since Windows is a first-class clinic deployment target per
the doc's own OS-keystore step. @signalapp/sqlcipher (Signal Desktop's
own SQLCipher binding) has a verified working win32-x64 prebuild and a
synchronous, better-sqlite3-like API. Trade-off: it's AGPL-3.0-only,
versus @journeyapps/sqlcipher's BSD-3-Clause -- a real licensing
consideration for this codebase, flagged and approved before use, not
picked silently.
Verified against SQLCipher's own docs and empirically: SQLCipher has no
GCM mode. The pragma is silently accepted but has no effect --
PRAGMA cipher still reports aes-256-cbc afterward. SQLCipher's real
authenticated encryption is AES-256-CBC + HMAC-SHA512 (both defaults, left
untouched). src/cache.ts does not set this pragma; see the comment there.
Verified empirically: PRAGMA kdf_iter only affects SQLCipher's own
passphrase-based key derivation. This Stub supplies a raw, pre-derived key
(x'<hex>' syntax) from HKDF-SHA256 over the facility private key, which
bypasses that KDF step entirely. Set anyway for spec fidelity -- it does
not weaken or strengthen anything here.
npm run install-integrity-baseline computes a single HMAC-SHA256 over
every .ts/.js file in src/ and scripts/ plus package-lock.json
(sorted by path, path and content both hashed so a rename or a swap
between two same-sized files is caught, not just an edit), EdDSA-signs
that hash with the facility private key, and writes integrity/ baseline.hmac read-only (attrib +r on Windows, chmod 444 elsewhere).
npm run start recomputes and verifies the same manifest on every
startup and every 6 hours thereafter (src/integrity-check.ts).
Gap 1 CLOSED: a startup mismatch now refuses to start, with a 60-second
emergency-override grace period. Originally deferred over the risk of
bricking a clinic machine on a stale baseline; now closed with a middle
ground rather than left open indefinitely. On a startup violation:
the Stub logs INTEGRITY_VIOLATION, sends an alert, then prints a
warning every 10 seconds for 60 seconds ("Server will refuse to start in
Xs... set HUUID_INTEGRITY_OVERRIDE=1... and restart within 60 seconds").
If nothing overrides it, process.exit(1). If HUUID_INTEGRITY_OVERRIDE=1
is set when the Stub is (re)launched, it starts immediately instead --
logs the override, sends a second alert with override: true, and
/health carries integrity_override_active: true plus a warning
field on every response for as long as that process runs. The override
is read once at startup (matching "restart within 60 seconds" -- a new
process launch with the var already set, not a live process somehow
observing an external env change mid-countdown), so a genuine attacker
who doesn't know to set it is locked out; a false positive (the CRLF
gotcha below, or a legitimate update) is a documented IT-technician
recovery path; a real emergency lets a clinician override and keep
treating the patient either way. Not yet true: that the Root Authority
is "alerted." Every violation and override is signed and written to the
resolver's huuid_stub_integrity_log (verified, immutable as of Gap 2 +
the immutability trigger), but nothing emails or pages a human today --
the row just sits there until someone queries it. Tracked as an open
pre-pilot gap, not glossed over as "alerting."
Scoped to startup only -- the 6-hour periodic recheck still uses the
original soft-fail behavior (log + alert + keep running); forcibly
killing a server that's been running fine and serving patients for hours
over a later recheck would be a new and much riskier behavior this
wasn't asked to add.
Real gotcha, not a bug: git checkout/git pull on Windows can trip a
false-positive violation. Found while testing the tamper-then-restore
flow -- restoring a file via git checkout produced a byte-different
file (CRLF line endings, from Windows' core.autocrlf) even though the
text content was identical to what the baseline was computed from (LF).
The manifest hashes raw bytes, so it correctly flagged this as changed --
which is the check working as designed, but it means re-running npm run install-integrity-baseline after any git pull that could have
touched line endings is a real operational step, not just after
intentional code edits. Worth knowing before this trips someone up in a
real deployment.
HMAC key is HKDF-derived, domain-separated from the cache encryption
key. Same root secret (facility private key), different salt/info
strings (:integrity-baseline-v1 / huuid-integrity-key vs.
cache-key.ts's :cache-encryption-v1 / huuid-cache-key), so a leak or
reuse of one derived key says nothing about the other. Not strictly
load-bearing for tamper-detection -- the EdDSA signature over the
manifest hash is what actually prevents forgery -- but it keeps the raw
manifest fingerprint tied to a specific facility rather than being a
plain, publicly comparable hash.
Self-verification, not a fetch. verifyManifestSignature checks the
baseline's signature against a public key derived from the SAME private
key bytes this process already holds (Ed25519 public keys are always
deterministically derivable from the private seed -- see
buildEd25519KeyObjectFromRaw in facility-key.ts), not against
huuid_facilities.public_key_multibase on the resolver. This answers
"did this Stub's own key sign this baseline," which is what startup
tamper-detection needs; it is not a claim that some other party has
independently attested to the key's legitimacy.
Gap 2 CLOSED: the resolver now verifies the alert signature before
logging anything. POST /1.0/stub-integrity looks up the claimed
facility's public key (huuid_facilities.public_key_multibase), verifies
the payload's signature against manifestHash, and rejects rather than
logs an unverified request: unknown facility -> 403, missing/invalid
signature -> 401. Only a verified alert is written, with
signature_verified: true recorded alongside it. Verification uses the
exact same raw-bytes construction the Stub signs with (Buffer.from( manifestHash, 'utf8'), no pre-hash) -- not the Break-Glass
requestSignature pattern (SHA-256 of a canonical-JSON body first,
bg-request-signature.ts), which is a different construction that would
make every genuine signature fail to verify if used here by mistake. See
huuid-resolver/api.md for the full endpoint contract and
huuid-resolver/lib/stub-integrity-signature.ts for the verification
code. An override column was also added to huuid_stub_integrity_log
(migration 008) -- a gap Gap 2's own combined-testing requirement
surfaced but its stated scope didn't cover: an override alert (Gap 1)
needs somewhere to record override: true, which didn't exist until
this was added.
Resolution priority order (Section 3.1): live resolver -> local cache
(fresh) -> stale cache (warn) -> QR card scan -> not found. This build
step implements tier 4: GET /1.0/resolver-public-key on the resolver
(unauthenticated, publishes the resolver's signing public key), npm run download-keys (fetches it, caches keys/resolver-public-key.json read-
only), src/qr-verifier.ts (verifyQRToken, fully offline -- no network,
no filesystem, no cache access, receives only the resolver's public key
bytes), and POST /qr/verify (the authenticated route that calls it and
persists a verified result to the local cache).
Deliberate deviation from the spec doc: .json, not .pem.
HUUID-EMR-STUB-v0.1.2.docx Section 4 step 5 names resolver-public- key.pem. The resolver's endpoint returns JSON (publicKeyMultibase,
keyId, validFrom, algorithm) -- keyId and validFrom have no
natural home in a bare PEM file, and both are needed for /health and
/debug/resolver reporting, not just the raw key bytes. Same treatment
as the AES-CBC-vs-GCM spec/implementation variance elsewhere in this doc.
Expired-but-signed tokens are not rejected. Per spec: an expired QR
token with a valid signature still returns valid: true, expired: true
with blood type and allergies intact, plus a warning telling the
clinician to re-verify once connectivity returns. The alternative --
refusing to show anything for an expired card -- is worse for emergency
care than showing possibly-stale-but-signed data with a visible warning.
Only an invalid signature blocks data (valid: false, 400, no health
fields in the response at all -- enforced inside qr-verifier.ts itself,
not just at the route, so a tampered card's data can never leave the
verification function in the first place).
Real, load-bearing honesty note: test tokens and the "resolver's" own
published key are currently the same key. Both GET /1.0/resolver-public-key and this build step's test tokens are signed
with HUUID_TEST_FACILITY_JWK -- the only signing keypair that exists in
this shared build/test environment, also used throughout this repo as the
seeded test facility's key. That means every DoD test in this step
(valid: true for a good token, Invalid signature for a tampered one)
proves the verification logic is correct, but does not prove
signer/verifier key separation, because there is no separation to prove
here -- issuer and verifier keys are identical by construction. What
this means for production: the resolver needs its own distinct signing
keypair, generated and held only by the Root Authority, used exclusively
to sign patient QR cards at enrollment -- never a facility's own key, and
never reused for JWT/ProviderJWT/Break-Glass signing the way the shared
test key currently stands in for multiple roles. Nothing in this codebase
issues a real QR card yet, either -- only verification is built. Both are
tracked as pre-pilot items, not silently assumed solved by these tests
passing.
Non-fatal by design. A missing or unreadable keys/resolver-public- key.json logs a warning at startup and leaves qr_verification: "no_key" (or "error" if the file exists but is malformed) on /health
and npm run diagnostics -- it does not stop the Stub from starting or
serving tiers 1-3. QR is a fallback tier, not a core requirement.
/debug/qr now actually verifies. It used to be an honest "not
implemented yet" stub. Now that verification is built, leaving that page
saying so would repeat the exact overclaiming mistake this build already
corrected once this session (see the integrity-override honesty fix
above) -- so it's now a real interactive test page (paste a base64url
payload, see the verification result), unauthenticated on purpose like
/debug/resolver (a human opens it directly in a browser and can't
attach X-Local-Auth to a form submit). It calls verifyQRToken()
directly and never writes to the patient cache -- there is no
localPatientId context on this page, only a pasted test payload, so it
stays read-only by design. It does share the same in-memory "last 5
verifications" log as the real POST /qr/verify endpoint, both surfaced
on /debug/resolver.
npm install
cp config-template.env .env # fill in your facility's values
npm run generate-local-secret
You also need a facility Ed25519 private key (PKCS8 PEM) at the path set by
HUUID_FACILITY_PRIVATE_KEY_PATH. npm run download-keys cannot fetch
this for you yet -- the live resolver has no key-distribution endpoint.
Place it manually until that exists.
npm run start
Known gaps (flagged, not hidden)
- No local-ID-to-DID linking mechanism yet.
verifyPatient(localPatientId, ...)has nodidparameter, but the resolver can only resolve an already-knowndid:huuid-- it has no arbitrary local-ID lookup. The only mechanism the spec describes for establishing that mapping is a QR-card scan at first encounter, which is explicitly deferred this step. For now, a cache-misslocalPatientIdis passed directly to the resolver as a candidate DID. Real MRNs will not resolve until QR linking exists. download-keysfetches the resolver public key, but not the facility private key.GET /1.0/resolver-public-keyexists now (Month 4, QR verification) and this script uses it successfully. The other half of the original gap remains open: no endpoint exists anywhere on the live resolver to serve a facility's signing key, soHUUID_FACILITY_PRIVATE_KEY_PATHstill requires a manually-placed PEM.- No real QR card issuance exists. This build step implements verification only. There is no code anywhere that generates or signs a patient QR card -- see "QR card verification (tier 4, P4)" above for what that also means about test-token key reuse.
- No Root Authority alerting on integrity violations or overrides.
Rows are written to the resolver's
huuid_stub_integrity_log(verified, immutable) but nobody is emailed, texted, or paged. Needs a real domain for the resolver project and a confirmedRESEND_API_KEY-- an operator decision, tracked inhuuid-resolver/api.md. - The live resolver does not yet return
bloodType/criticalAllergies. The example DID Document in the Resolution Spec (Section 2.1) includes anofflineTokenwith these fields, but the actual Month 2/3 resolver's stored DID documents (seehuuid-resolver/supabase/migrations/001_initial.sql) only carry the standard W3C DID fields. This Stub parsesofflineTokendefensively and will pick the fields up automatically once the resolver starts returning them -- no code change needed here. config-template.envhas no "Section 6" to copy from. The spec document only goes up to Section 5. OnlyHUUID_FACILITY_DID,HUUID_API_KEY, andHUUID_FACILITY_CODEare doc-mandated fields; everything else in the template is there because this code needs it, commented as such.
| Script | Status |
|---|---|
start |
Implemented |
test-connection |
Implemented -- pings resolver health, then verifies the facility certificate via a probe resolution |
diagnostics |
Implemented |
generate-local-secret |
Implemented |
check-permissions |
Implemented |
install-service (Windows) |
Implemented -- generates a wrapper + prints sc.exe commands; does not self-elevate |
install-systemd (Linux) |
Implemented -- generates a unit file + prints systemctl commands; does not self-install |
download-keys |
Implemented -- fetches GET /1.0/resolver-public-key, caches keys/resolver-public-key.json read-only. Facility private key download is still not implemented (no endpoint exists for that). |
secure-keys |
Implemented -- imports the facility key to the OS keystore, verifies the roundtrip, then shreds + deletes the PEM. Halts without deleting anything if verification fails. |
install-integrity-baseline |
Implemented -- signs a manifest of src//scripts//package-lock.json with the facility key, writes it read-only. Warns and asks for confirmation before overwriting an existing baseline. |
npm run diagnostics also verifies cache encryption is active (checks the
DB file lacks SQLite's plaintext magic header) and reports Cache: ENCRYPTED;
reports Key storage: KEYSTORE / FILE / MISSING with a warning or error as
appropriate; and reports Integrity baseline: EXISTS/MISSING, Last integrity check: PASS/FAIL/NOT RUN, and the 6-hour check interval (running
its own fresh integrity check first, since it's a separate process from any
running npm run start and has no other way to know the current state).
Note on scripts/secure-keys.ts's location: this build step's brief
named src/scripts/secure-keys.ts as the file path. The repo's established
convention -- and the already-registered package.json script -- puts every
CLI script at the top-level scripts/, so the implementation lives at
scripts/secure-keys.ts (same place as the rest) rather than creating a
second, disconnected path under src/.
| Route | Auth | Purpose |
|---|---|---|
GET /health |
none | Local system status (no external calls) |
POST /verify |
X-Local-Auth |
The EMR integration surface -- calls verifyPatient() |
POST /qr/verify |
X-Local-Auth |
Resolution tier 4 -- offline QR card token verification, calls verifyQRToken() |
GET /debug/resolver |
none | Local cache contents + resolver public key status + last 5 QR verifications (developer page, mirrors the resolver repo's own /debug/resolver) |
GET /debug/qr |
none | Interactive QR token verification test page (paste a payload, see the result) |