Skip to content
Browse documentation

OPRP 0.1.0-dev.11 · Draft documentation

OPRP experimental wire profile 0.1

This document describes the current executable profile, not a frozen or production normative standard. Uppercase MUST in this document describes prototype interoperability requirements only. All OPRP-0.1 algorithm and relation identifiers are experimental. Implementations of ordinary FHIR, OAuth, SMART, JOSE, or HPKE do not automatically implement this profile.

Discovery

An account such as synthetic@patients.example is represented as acct:synthetic@patients.example. Initial addresses use a restricted ASCII local part, preserve its case, and require lowercase ASCII DNS domains. Email delivery, international local parts, aliases, account reuse, and IDN normalization are outside the implemented grammar.

The client obtains a JRD document at:

GET /.well-known/webfinger?resource=acct%3Asynthetic%40patients.example
Accept: application/jrd+json

DNS resolves the address domain as usual. The document has subject equal to the account URI and exactly one link with rel: urn:oprp:experimental:0.1:host. Its properties.delegation contains payload and signature. Payload decodes to these fields: v, issuer, address, record_id, endpoint, host, credential, issued_at, expires_at, generation. The outer unsigned JRD can use ordinary JSON whitespace; the signed embedded payload uses the compact byte profile below.

The signature covers ASCII OPRP-DISCOVERY-0.1. followed by the encoded payload. A separately configured directory root MUST be authorized for the requested address domain. Verify that signature, exact address, expiry, monotonic minimum generation when available, HTTPS endpoint, Host identity, and Host credential. A provider can delegate to another storage domain. A new client has no remembered generation and cannot detect every otherwise-valid rollback. The initial directory lifetime is at most 600 seconds.

The reference authority signs the entire delegation directly. It is not yet an X.509 chain, DNSSEC profile, operational issuance service, or public trust list. DNS and WebFinger require no Convey endpoint; prototype roots are explicitly configured by each participant. HTTPS authenticity alone does not supply the required post-quantum binding.

Outer HTTP contract

Encrypted operations use POST to the advertised exchange endpoint. The demonstration path is /oprp/exchange. It carries application/oprp+json, an unregistered experimental media type. GET is used for WebFinger, never for record searches with encrypted bodies. A production registration and capability document remain to be designed.

The body contains exactly protected, credential, kem, nonce, ciphertext, tag, and signature. Except credential, values are unpadded canonical base64url. credential is an object containing exactly payload and signature.

The SDK sends the access JWT inside the encrypted body, rather than an ordinary Authorization header. This preserves the user's desired requester token while protecting its patient/scoping context at the payload layer. It is a deliberate prototype deviation from conventional OAuth bearer placement. The requester authenticates the outer request with its credential and signature, so the Host can verify identity before decrypting the token. A legacy gateway would need an explicit adapter; moving the JWT to a visible header is not automatic compatibility.

A valid protected exchange returns HTTP 200 and an encrypted response, including application errors. A request that cannot be authenticated/decoded receives HTTP 400 with no body in the reference server. Unsupported routes return HTTP 404. Responses use Cache-Control: no-store. The SDK verifies HTTPS certificates, expected content type, Host identity/key, and response-to-request binding. Redirects are not followed.

Keys and identity credential

Use separate ML-DSA-65 signing and ML-KEM-768 encryption keys. Public wire keys are raw FIPS byte strings; Node wraps them in SubjectPublicKeyInfo only for its native API. Their key IDs are base64url SHA-512 of the raw key bytes.

The credential payload contains exactly subject, signingKey, encryptionKey, signingKid, encryptionKid, issuer, serial, kind, roles, nbf, and exp. kind is host, application, or person. Signature input is ASCII OPRP-CREDENTIAL-0.1. plus the encoded payload. Validate the configured issuer, signature, time, revocation, expected subject/kind, and public-key fingerprints. Issuer policy constrains kind, subject namespace, roles and lifetime; fresh signed status and durable serial/key/issuer denial are mandatory in the reference verifier. See credential lifecycle for exact signed status and rotation bytes. Current development credentials last one day; this is not a production protocol lifetime recommendation.

Protected header and cryptographic bytes

The decoded protected header contains exactly:

{"v":"0.1","suite":"OPRP-0.1-MLKEM768-MLDSA65-HKDFSHA512-A256GCM","sender":"urn:oprp:demo:hospital","recipient":"urn:oprp:demo:host","signingKid":"<SHA-512 key ID>","encryptionKid":"<SHA-512 key ID>","messageId":"<unique ID>","issuedAt":0,"expiresAt":120,"inReplyTo":null}

Times are safe nonnegative integer Unix seconds. Envelope lifetime MUST be positive and no more than 120 seconds; the reference verifier allows issue time up to 30 seconds ahead of its clock. A response sets inReplyTo to its request's message ID; the SDK MUST check that binding. Application operations require an application credential; migration peer operations require a Host credential pinned by an independently admitted plan. A Host credential does not permit ordinary patient application operations. The Host rejects a request carrying a non-null reply binding and durably remembers accepted message IDs until expiry.

Define frame(parts...) as concatenation of each part's four-byte unsigned big-endian byte length followed by its bytes. ASCII strings below have no terminator. Define:

AAD = frame(UTF8("OPRP-ENVELOPE-0.1"), ASCII(protected), kem_bytes, nonce_bytes)
salt = SHA-512(AAD)
payload_key = HKDF-SHA-512(shared_secret, salt, ASCII("OPRP-0.1/payload-key"), 32 bytes)
ciphertext, tag = AES-256-GCM(payload_key, nonce, plaintext_json, AAD)
signature_input = frame(AAD, ciphertext_bytes, tag_bytes)
signature = ML-DSA-65.Sign(signing_private_key, signature_input, empty_context)

kem_bytes and shared_secret come from encapsulation to the authenticated recipient ML-KEM-768 public key. Every message uses a fresh encapsulation and random 12-byte GCM nonce. GCM tag length is 16 bytes. Pure ML-DSA signs the message directly; this profile does not use HashML-DSA or a prehashed signature variant. The receiver MUST verify credentials, recipient and time bindings, and signature before decapsulation and authenticated decryption. No alternate suite or plaintext fallback is accepted.

Item Bytes before base64url
ML-KEM-768 public key 1,184
ML-KEM-768 ciphertext 1,088
KEM shared secret 32
ML-DSA-65 public key 1,952
ML-DSA-65 signature 3,309
AES key / nonce / tag 32 / 12 / 16

This is a custom experimental composition of standard primitives. It does not claim HPKE compatibility, a reviewed post-quantum JOSE suite, FIPS module validation, or forward secrecy. Static recipient keys permit later compromise to decrypt recorded messages. See threat model.

JSON and limits

The initial implementation accepts compact UTF-8 JSON objects that exactly round-trip through its serializer. It rejects whitespace alternatives, duplicate keys, alternate escapes/numeric forms, noncanonical base64url, and unexpected object fields. Signed structures preserve the encoded payload's actual bytes; receivers do not reconstruct signature input from parsed field order.

This is not RFC 8785 canonical JSON. Node and Python can differ on floating-point rendering, negative zero, and some Unicode/numeric cases. The independent test covers the specified synthetic values; it does not establish universal FHIR JSON compatibility. Canonical encoding and cross-runtime numeric vectors are a release gate. Maximum plaintext is 1 MiB; outer HTTP is bounded to 1,600,000 bytes, and attachments/streaming are unsupported.

Encrypted operations

Every inner request has an operation. Unsupported operations return an encrypted status: error and stable error code. The reference contract currently supports:

Operation Additional request fields
token request containing the access request below
authorization_status request_id
cancel_authorization request_id
rotate_requester proof with old/new credentials and signatures; exchange uses the new keys
replace_token existing grant_id; no new scope or period fields
export_record patient, access_token; separate export grant required
refresh refresh_token, stable logical request_id
read_demographics patient, access_token
list_labs patient, access_token
update_demographics patient, access_token, operation_id, base_version, resource: {name: string}
create_lab patient, access_token, operation_id, limited FHIR Observation resource
sync_snapshot patient, access_token; optional limit (1–100), page_cursor
migration_stage / migration_stage_final Host sender only; signed plan, signed archive; destination owner admission required
migration_activate Host sender only; transfer_id, signed release
migration_discard Host sender only; transfer_id, signed pre-release abort
changes patient, access_token, opaque cursor, limit (1–100)

Read responses return status: ok, a resource, and a reference-Host version for demographics. Lab search returns a FHIR searchset Bundle. These are illustrative operations, not the eventual complete encrypted FHIR interaction mapping. Six clinical types support version history and logical deletion; general transactions, attachments and US Core conformance remain unimplemented.

Access and representation

Access request fields are exactly request_id, patient, scopes, requested_duration_seconds, refresh_requested, and optional actor_proof. Supported durations are positive integer seconds, at most 31,536,000 in this prototype. The Host may approve less. The authorized period begins when the Host grants access, including after a pending decision, and ends at authorized_until.

Current scope strings are:

  • patient/Patient.r
  • patient/Patient.u
  • patient/Observation.rs?category=laboratory
  • patient/Observation.c?category=laboratory
  • oprp/record.export
  • oprp/record.transfer (reference management right; public requester transfer-management API remains work)

The Host evaluates each requested scope and period. Mixed requests use all-or-nothing pending: permitted scopes are not issued while another requested scope awaits approval. pending returns request_id, only approval_required scopes, and expires_at; the Host atomically creates a durable approval task. A Host-owned worker must deliver that task through its own authenticated patient/representative process. The demo simulates this process, without sending notifications. Repeated identical requests reuse the task; changing a logical request under its existing ID is rejected.

The reference pending lifetime is one hour. Requester cancellation, trusted patient/representative denial, and expiry produce terminal denied results with request_cancelled, patient_denied, or request_expired. Those request IDs cannot revive access. Cancelling an already granted request fails with request_not_pending; grant revocation is a separate trusted Host action. On polling, the Host reevaluates pending scope/time and authority: newly sufficient standing policy can issue automatically, and a prohibited request is denied. New-request limits and pending-task caps are Host-configurable; quota errors create no extra approval task. Defaults are illustrative Host policy, not universal protocol limits.

denied returns a reason. granted returns grant_id, access_token, token_expires_at, authorized_until, scope, and, if issued, refresh_token and refresh_expires_at. The illustrative policy allows one-day demographic reads in advance and requires approval for broader scope or time. It does not implement real emergency eligibility or legal consent policy.

The access token is a compact JWT with private experimental alg: OPRP-MLDSA65-v0.1, typ: JWT, and the Host signing key ID. Claims are iss, aud, sub, iat, nbf, exp, jti, patient, scope (an array), grant_id, cnf: {oprp_signing_kid}, and actor (person ID or null). iss and aud are the Host identity; sub is the requesting application. This is not a registered JOSE algorithm or a claim of full SMART/OAuth conformance. Each JWT is at most five minutes in the reference Host and cannot outlive the grant. Every use checks current grant and representative status.

Actor proof contains a person credential, encoded payload, and signature. Its payload binds actor, app, patient, request_id, and expires_at. The person signs ASCII OPRP-ACTOR-0.1. plus payload with their own key. The Host independently verifies the person's active, scoped representative authority. A relationship label supplied by the requester is insufficient.

Refresh credentials are random 32-byte opaque values, application-key bound, rotating, and absolutely bounded by grant expiry. Refresh accepts no scope or period expansion. Normal lookup stores hashes; recovery additionally stores a short-lived encrypted successor receipt. Within 120 seconds, an identical logical request can recover the same unused successor and a fresh bounded JWT. Different-ID reuse revokes the grant. A superseded or expired identical retry returns a recovery error without returning a later credential. SDK state persistence can preserve the original ID across restart; independent processes still need coordination. See refresh recovery for the precise contract and review boundaries.

Receipts, conflicts, and errors

The reference Host keys receipts by a SHA-512 fingerprint of the compact JSON tuple [application, patient, operation_id]. It commits a mutation, result receipt, and success audit in one SQLite transaction. A retry first rechecks authorization, then compares the operation/resource/base-version digest and returns the original receipt. Operation IDs reused for different content yield operation_id_conflict; stale demographic versions yield version_conflict. Receipt retention is currently indefinite. Signed archives preserve logical receipt context for restore; after destination authorization, unchanged delayed operations can recover those receipts. Coordinated live migration now fences source writes, binds the exact final archive to signed ready/release/activation receipts, and uses new destination authorization. See Host migration. Write-only responses expose submitted fields/identifiers, including on receipt recovery, rather than other Patient fields.

Examples of stable errors include unsupported_scope, invalid_duration, insufficient_scope, invalid_token, wrong_token_holder, authorization_expired_or_revoked, representative_not_authorized, request_id_conflict, operation_id_conflict, and version_conflict. Credential, envelope, and transport failures are distinguishable internally but not exposed as plaintext HTTP diagnostics. Additional lifecycle errors include missing_issuer_status, stale_issuer_status, authority_constraint_violation, revoked_credential, disabled_issuer, status_rollback_or_equivocation, requester_rotation_required, and invalid_rotation. Archive errors include archive_too_large, invalid_archive_signature, destination_record_exists, and legacy_receipt_context_missing. Migration errors include transfer_not_admitted, wrong_transfer_host, migration_not_active, migration_fenced, record_moved, migration_archive_mismatch, migration_catchup_incomplete, migration_irreversible, discovery_cutover_conflict, queue_route_update_required, and transfer_expired. The full error/recovery catalogue is still being specified.

Snapshot and change responses

sync_snapshot returns status: ok, scoped resources: [{resource, version}], and an opaque cursor from one consistent transaction. changes returns ordered events: [{event_id, kind, resource, version}], its next cursor, and has_more. The current event kinds are created, updated and clinical-free deleted. Identical write retries append no duplicate event. Retried pages can repeat stable event IDs, so clients atomically apply/deduplicate a page and save its next cursor.

Cursors are encrypted/authenticated Host-internal values bound to Host/feed epoch, application key, patient, grant, visibility and expiry. They reveal no raw event position. Pages, counts and retention-gap checks include only authorized patient/types. A retained-history gap or elapsed cursor returns cursor_expired; clients recover with a fresh scoped snapshot. Changed grant/caller context returns cursor_context_changed; a changed grant visibility epoch returns visibility_changed. Cursor life is at most one hour and no later than grant expiry. See synchronization for storage semantics and limitations. Search/history/baseline reads use stable bounded sessions and page_cursor; see pagination and mirrors. Baseline feed cursor stays constant across its pages.

Optional subscription callback profile

subscribe adds subscription_id, HTTPS callback, authorized topics and absolute expires_at to the common protected patient/access-token fields. subscription_status and unsubscribe add only subscription_id. Registration uses the current grant and cannot extend scope or approved time. The Host worker proves callback control with a signed encrypted challenge before activation, then sends minimal change_notification bodies with stable logical delivery IDs. Requesters acknowledge through encrypted replies and retrieve clinical content through normal authorized synchronization.

Retries use fresh envelopes and preserve logical delivery IDs; multiple changes may coalesce. Current grant, representative, credential/status, requester key pins and Host custody are checked before sending. The application receiver authenticates a pinned Host and commits a receipt/pull task before acknowledgment. Polling remains sufficient without callbacks. See subscriptions, conformance/recovery and callback body schema. These topic strings are an OPRP experimental profile; standard FHIR Subscription/SubscriptionTopic resource and backport mapping is future work.

Expanded clinical request profile

Prototype 0.1.0-dev.9 adds read_resource, search_resources, create_resource, update_resource, delete_resource and resource_history for six additional clinical types, with independent .rs/.c/.u/.d scopes. Common fields bind patient, access token and resource type. Updates/deletes carry a base version and mutations carry a stable operation ID. Replies are metadata-only; historical reads require read authority. The feed includes clinical-free deletion markers and expanded archives retain admitted histories/deleted entries.

capabilities is an authenticated encrypted application operation reporting the supported generic types/interactions, protocol/FHIR version and optional subscriptions. It does not claim a standard FHIR CapabilityStatement or a RESTful FHIR endpoint. See clinical operation schema, coverage/profile limits and packaged SDKs.

Visibility profile update

The Host advertises visibility_profile: "oprp-visibility-1". Visibility and cache invalidation defines trusted grant policy, exact security labels, SUBSETTED field projections, write/export protection and required baseline/change visibility_epoch. Changed visibility invalidates prior cursors and clears SDK mirrors before a new baseline; disconnected clients cannot learn unknown permission changes. Negative conformance records authenticated rejection tests. Host policy and approval delivery remain local integration choices.

External clinical validation update

The external clinical validation guide now supplies versioned six-type R4 StructureDefinitions, local package/checksums, a pinned HL7 validator, selected US Core 9.0.0 targets and a trusted optional Host admission gate. Run npm run setup:validator, npm run package:profiles and npm run clinical:conformance with Java installed. Terminology remains disabled; a successful example does not establish complete US Core server or clinical conformance. Host policy, authorization and patient approval remain separate.

View source

Documentation follows the experimental reference implementation.