Skip to content

Reference source · 0.1.0-dev.11

wire-profile.md

docs/wire-profile.md · Download original file

# 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:

```http
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](credential-lifecycle.md) 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:

```json
{"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:

```text
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](threat-model.md).

## 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](refresh-recovery.md) 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](host-migration.md). 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](synchronization.md) for storage semantics and limitations. Search/history/baseline reads use stable bounded sessions and `page_cursor`; see [pagination and mirrors](pagination-mirrors.md). 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](subscriptions.md), [conformance/recovery](subscription-conformance.md) and [callback body schema](../schemas/subscription.schema.json). 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](../schemas/clinical.schema.json), [coverage/profile limits](fhir-coverage.md) and [packaged SDKs](sdks.md).

## Visibility profile update

The Host advertises `visibility_profile: "oprp-visibility-1"`. [Visibility and cache invalidation](visibility.md) 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](negative-conformance.md) records authenticated rejection tests. Host policy and approval delivery remain local integration choices.

## External clinical validation update

The [external clinical validation guide](external-clinical-validation.md) 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.