Sealed invocations 

    Sealed invocations are Basil's transport-neutral COSE contract for callers that cannot reach the typed broker services over the local Unix socket. A courier can move the bytes, but it cannot decrypt the request, assert the actor, pick the operation result, or learn key material.

    Use this page as the wire contract for bridged transports and peer messages. Basil uses a strict COSE profile: requests and responses are signed and encrypted COSE objects, NATS carries raw COSE bytes, and the gRPC invocation service is only a small local carrier around those bytes.

    ⚠️ Not a second authentication system

    Sealed invocation does not restore bearer tokens, uid spoofing, or a metadata principal. The actor is proved by a signature-key subject inside the signed COSE message, then Basil applies the same default-deny policy and catalog grants as a local socket call.

    Enable the service 

    InvocationService.Invoke is registered on the broker gRPC server, but it rejects requests until an operator enables it explicitly:

    [broker-identity]
    id = "basil://prod/us-east-1/agent-a"
    response-signing-key-id = "broker.response_signing.2026q3"
    
    [invocation]
    enable = true
    audience = ["basil://prod/us-east-1/agent-a"]
    request-encryption-key-id = "broker.request_encryption.2026q3"
    max-ttl-secs = 60
    clock-skew-secs = 30
    replay-cache-capacity = 4096
    FieldMeaning
    enableAccept sealed invocation requests. Defaults to false.
    audienceBroker audience values this agent accepts.
    request-encryption-key-idCatalog key id for the broker request encryption key.
    max-ttl-secsMaximum explicit request TTL. Default 60.
    clock-skew-secsAccepted issue-time and expiry skew. Default 30.
    replay-cache-capacityIn-memory replay-cache entry cap. Default 4096.

    When enable = true, [broker-identity] id must be a basil:// URI and response-signing-key-id must name the broker signing key. The request encryption key must be a class: sealing catalog key marked for request encryption. Response encryption keys supplied by callers must also be class: sealing and marked for response encryption, so the broker can produce a protected response without seeing the caller's private key.

    Message structure 

    Basil's invocation profile uses signed sealed messages for broker requests and responses:

    tagged COSE_Sign1
      protected: { alg: EdDSA or ES256, kid: sender signing key id }
      payload:   tagged COSE_Encrypt
        protected: { alg: content encryption, content type, CWT claims, Basil labels }
        payload:   encrypted deterministic CBOR operation body
        recipient: X25519 ECDH-ES + HKDF-256 recipient
      signature: Ed25519 or ECDSA P-256 signature over COSE Sig_structure

    The outer COSE_Sign1 authenticates the sender. The embedded tagged COSE_Encrypt hides the body and binds the protected headers through COSE Enc_structure AAD. Basil must receive the exact tagged bytes that were signed and encrypted. Parsing a message into another shape and re-encoding it changes the bytes that COSE authenticates.

    The same profile also supports seal-only peer messages: a bare tagged COSE_Encrypt without an outer signature. Use that only when confidentiality to a peer key is enough and sender authenticity is supplied elsewhere. For brokered in-place decrypt, call AeadService.UnsealCose with the complete tagged COSE_Encrypt bytes and the matching encryption-layer external_aad.

    Protected claims and labels 

    Temporal, audience, and correlation claims live in the encrypted layer's protected header. Standard CWT claims use header 15; Basil adds private labels in the RFC 9052 private range:

    LabelNameRoleMeaning
    -70001in_reply_toResponseRequest message id answered by this response.
    -70002request_hashResponseSHA3-256 of the complete tagged request bytes.
    -70003sender_key_idRequest, response, peerSender key id. Must equal the outer kid on signed sealed messages.
    -70004response_key_idRequestCaller-selected key that the broker response must be sealed to.
    -70005response_subjectRequestOptional courier route for the response.

    The role determines which labels are legal:

    RoleRequiredForbidden
    Requestsender_key_id, response_key_id, CWT iat, CWT ctiin_reply_to, request_hash
    Responsein_reply_to, request_hash, CWT iat, CWT ctiresponse_key_id, response_subject
    Peersender_key_id, CWT iat, CWT ctiin_reply_to, request_hash, response_key_id, response_subject

    cti is the message id. It is replay state, not a bearer secret. It must be unique for the sender inside the replay window. iss is the sender subject when present, aud is the broker audience when present, iat is required, and exp is optional. When exp is absent, Basil applies the configured default TTL.

    Signer certificate headers (-70006

    The base profile trusts a signer only by a pinned kid: a verifier holds broker or peer signing public keys out of band and rejects anything else. Some deployments instead want a message to carry the trust chain for its own signer, so a header-aware verifier can resolve an unfamiliar kid from the message itself. Basil supports that with one optional protected header on the outer COSE_Sign1:

    LabelNameMeaning
    -70006signer_certificates_jwtArray of compact trusted-signer certificate JWTs for the outer signing kid.

    The label is crit, so a verifier that does not understand it fails closed rather than silently ignoring the chain. The strict codec round-trips the array byte-for-byte like every other protected value, and the decoded value is exposed through the ProtectedHeaders type alongside the CWT claims.

    Verifier::verify receives the decoded ProtectedHeaders on its single verify call. A header-aware verifier can use signer_certificates_jwt to resolve and trust the signer kid from the message; a verifier that pins keys out of band ignores the header and pins as before. Either way, a successful check still proves only that the pinned or resolved key signed the exact Sig_structure bytes.

    📝 Note

    Signer certificate headers are an extension beyond the base sealed-invocation profile, added for downstream COSE interoperability. The base broker request and response flow pins broker signing keys out of band and does not populate -70006.

    Content types and bodies 

    The COSE content type header is a media-type string. Basil reserves the application/basil.* registry for invocation body schemas:

    Content typeBody schema
    application/basil.sign-requestSignInvocationRequest
    application/basil.sign-responseSignInvocationResponse
    application/basil.mint-jwt-requestMintJwtInvocationRequest
    application/basil.mint-jwt-responseMintJwtInvocationResponse
    application/basil.mint-nats-user-requestMintNatsUserInvocationRequest
    application/basil.mint-nats-user-responseMintNatsUserInvocationResponse

    Bodies are deterministic CBOR maps selected only by the protected content type. The current broker execution path returns protected application/basil.sign-response bodies for sealed Sign requests, including denied and invalid-request outcomes. The registry also defines the minting body contracts used by fixtures and client helpers.

    SignInvocationResponse carries status, policy_generation, and an optional signature. status is one of OK, DENIED, INVALID_REQUEST, or INTERNAL_ERROR. A denied or failed operation carries no signature bytes, but it is still a trusted result when the response verifies and opens.

    MintNatsUserInvocationRequest is a five-entry deterministic CBOR map:

    CBOR keyFieldMeaning
    1account_key_idAccount identity key or account signing key that signs the user JWT.
    2user_nkeyUser public NKey.
    3nameUser display name.
    4ttl_secsOptional whole-second token lifetime.
    5issuer_accountOptional owning account identity. Set it when account_key_id is an account signing key, so the minted JWT carries nats.issuer_account.

    Algorithms and strict validation 

    The profile is intentionally closed:

    PurposeAllowed algorithms
    SignatureEdDSA COSE codepoint -8 with Ed25519 keys, or ES256 COSE codepoint -7 with ECDSA P-256 keys.
    Key agreementECDH-ES + HKDF-256 COSE codepoint -25, X25519 keys.
    Content encryptionA256GCM COSE codepoint 3; ChaCha20-Poly1305 COSE codepoint 24 for peer use.
    Request bindingSHA3-256 of the complete tagged request bytes.

    The decoder rejects untagged or wrong-tag messages, indefinite lengths, non-minimal integers, non-deterministic encodings, duplicate or unknown labels, text labels, unknown algorithm codepoints, crit violations, claims in unprotected headers, missing payloads, and recipient arrays that are not exactly one recipient. After decoding, Basil re-encodes the parsed semantics and requires byte equality with the input. This check runs in release builds too.

    Nonces and X25519 ephemeral keys are generated by the COSE implementation. Production callers do not supply them. Secret intermediates are zeroized, low-order X25519 inputs are rejected, Ed25519 verification uses strict verification, and ES256 signatures are deterministic and low-S normalized.

    The checked fixtures include the sealed invocation body set, including the mint-nats-user-request vector with issuer_account, and an ES256 signed COSE_Sign1 fixture. The interop suite verifies ES256 COSE_Sign1 messages with veraison/go-cose in both directions.

    gRPC carrier 

    The local broker carrier is deliberately thin:

    ServiceMethodRequestResponse
    InvocationServiceInvokeSealedRequestSealedResponse

    SealedRequest contains one field:

    FieldMeaning
    messageComplete tagged request COSE_Sign1 bytes, exactly as received.

    SealedResponse contains:

    FieldMeaning
    messageComplete tagged response COSE_Sign1 bytes.
    response_subjectOptional clear courier route copied from request claim -70005.

    response_subject is not trusted response metadata. It exists so a courier can publish the protected response to a caller-selected subject. The caller must verify and decrypt SealedResponse.message before trusting any operation status or output.

    Clear gRPC status is transport and protocol status only. Once the broker accepts and opens a request, allow, deny, invalid body, and sanitized operation failures belong inside the signed and encrypted response body. If Basil cannot produce that protected response, it fails closed with a clear error and no trusted operation result.

    Brokered peer-message decrypt (UnsealCose

    A seal-only peer message is a bare tagged COSE_Encrypt with no outer signature. When its recipient is a backend-custodied X25519 sealing key, the holder cannot open it locally: the private half stays in the vault and is used in place. AeadService.UnsealCose opens such a message through the broker without ever exposing the key.

    FieldMeaning
    key_idCatalog X25519 class: sealing key the message is sealed to.
    cose_encryptComplete tagged COSE_Encrypt bytes, exactly as received.
    external_aadThe encryption-layer external AAD the sender bound (omit for none).

    The response carries only the recovered plaintext. The RPC is gated by op:decrypt on key_id: it reuses the AEAD decrypt permission rather than adding a new op, so granting an unseal is the same grant as granting decrypt on that key.

    Basil forwards cose_encrypt to the key verbatim and never re-encodes it. The COSE Enc_structure AAD embeds the exact serialized protected-header bytes, so any parse-and-re-encode round trip would change the authenticated bytes and break the AEAD tag. That is why UnsealCose is a bytes-in RPC and not a field on the typed Decrypt or UnwrapEnvelope calls.

    ⚠️ Fail-closed, and confidentiality only

    A wrong key_id, a mismatched external_aad, or any tampering with the ciphertext, protected headers, or KDF party identities fails closed with a decrypt error and no plaintext. A successful open proves only confidentiality to the recipient key; it never proves who sent the message. Sender identity comes only from an outer COSE_Sign1.

    Narrowing the unseal oracle with a catalog pin 

    By default, an op:decrypt grant on the sealing key_id opens any COSE_Encrypt addressed to that key. When that authority is too broad, pin the key in the catalog. A sealingPin on the class: sealing entry restricts UnsealCose to envelopes whose KDF partyU/partyV identities and/or encryption-layer external_aad match the pinned context; a non-matching envelope is refused PermissionDenied before the private key is materialized. If no pin is set, behavior is unchanged. This is least privilege on the unseal oracle: the same op:decrypt grant authorizes only the contexts you pin. See the catalog for the sealingPin schema.

    The request is bounded by the broker's configured maximum encrypt size. In Rust, the BrokerRecipient in basil::sealed_invocation forwards OpenRequest::cose_encrypt verbatim to this RPC; in Go, call Client.UnsealCose(ctx, keyID, coseEncrypt, externalAAD).

    NATS bridge courier model 

    basil-nats-bridge carries raw COSE bytes:

    NATS request payload: <tagged COSE_Sign1 request bytes>
    NATS reply payload:   <tagged COSE_Sign1 response bytes>

    The bridge checks only courier shape: reply subject, payload size, Basil availability, timeout, and whether a returned response_subject is a valid publish subject. It wraps request bytes as SealedRequest { message }, sends them to InvocationService.Invoke, and publishes SealedResponse.message unchanged to SealedResponse.response_subject when present or to the NATS reply subject otherwise.

    The bridge does not inspect protected headers, decrypt bodies, verify signatures, rewrite subjects, or fabricate operation results. It also needs no policy grant of its own: Basil authorizes the actor inside the sealed request (its signature-key proof, its op:decrypt on the request-encryption key, and the operation-specific grants on the requested key), never the process that presented it.

    Trust distribution 

    Requesters must learn broker response-signing public keys out of band, for example through operator configuration or another controlled trust bundle. Do not fetch trust anchors from the courier and do not trust a key id merely because it appears in a response.

    The Rust helper accepts a pinned map of broker signing key ids to Ed25519 public keys. A response is accepted only when the signature verifies under a pinned key, the response answers the original message id, the request_hash equals SHA3-256 of the exact request bytes, and the encrypted body opens with the caller-selected response key.

    Broker request encryption public keys are also distributed out of band. A caller seals the request to the configured broker request-encryption key id; Basil rejects requests addressed to any other recipient key.

    Replay and expiry 

    Before policy evaluation, Basil validates:

    CheckFailure meaning
    AudienceA present aud must match one configured broker audience.
    Issue timeiat cannot be beyond allowed clock skew.
    Expiryexp, or iat + default TTL, cannot be older than allowed skew.
    TTL capExplicit exp - iat cannot exceed max-ttl-secs.
    ReplayThe (sender_key_id, cti) pair must not already be in the replay cache.
    Role shapeRequest-only and response-only labels must appear only in their legal roles.

    The replay cache is local broker memory. Size it for the expected request rate over the maximum accepted TTL and clock skew. A repeated message id for the same sender inside the window is rejected even if the COSE bytes differ.

    Response protection 

    Basil signs every protected response with the configured broker response-signing key and seals it to the request's response_key_id. The response claims bind it to the request with in_reply_to and request_hash.

    Denied operations, invalid request bodies, unsupported content types, and sanitized internal operation errors are response bodies, not unsigned courier errors, whenever Basil can protect the response. Treat a verified DENIED, INVALID_REQUEST, or INTERNAL_ERROR exactly as a trusted operation result. Treat a clear bridge or gRPC error as "no trusted result".

    Rust helper usage 

    The Rust basil crate exports basil::sealed_invocation:

    use basil::{
        LocalSealedInvocationSigner, SealedInvocationBody, SealedInvocationOptions,
        SigningAlgorithm, prepare_sealed_invocation,
    };
    use basil_cose::KeyId;
    use basil_proto::invocation::SignInvocationRequest;
    use zeroize::Zeroizing;
    
    let signer = LocalSealedInvocationSigner::from_secret_bytes(
        KeyId::from_text("publisher.signing.2026q3")?,
        &Zeroizing::new(sender_private_seed),
    );
    
    let body = SealedInvocationBody::Sign(SignInvocationRequest {
        key_id: "publisher.signing.2026q3".to_string(),
        message: b"payload".to_vec(),
        algorithm: i32::from(SigningAlgorithm::Ed25519),
    });
    
    let prepared = prepare_sealed_invocation(
        SealedInvocationOptions {
            message_id: "018f0a5d-7f2d-7330-b6d1-102030405060".to_string(),
            issued_at_unix: 1_782_740_000,
            expires_at_unix: Some(1_782_740_030),
            sender_sign_id: "publisher.signing.2026q3".to_string(),
            sender_subject: Some("content.publisher".to_string()),
            recipient_key_id: "broker.request_encryption.2026q3".to_string(),
            recipient_subject: Some("basil://prod/us-east-1/agent-a".to_string()),
            response_encryption_key_id: "publisher.response.2026q3".to_string(),
        },
        &broker_request_encryption_public_key,
        &body,
        &signer,
    ).await?;
    
    let request = prepared.to_sealed_request();

    For protected Sign responses, use verify_and_decrypt_sign_response with the prepared request, the SealedResponse, the private half of the caller's response key, pinned broker signing keys, and response validation bounds. For broker-backed peer-message opening, BrokerRecipient forwards the embedded tagged COSE_Encrypt bytes to AeadService.UnsealCose without re-encoding them.

    Policy shape 

    A sealed invocation actor is a normal policy subject whose matcher contains a signature-key proof:

    {
      "schemaVersion": 2,
      "subjects": {
        "content.publisher": {
          "allOf": [
            {
              "kind": "signature-key",
              "algorithm": "ed25519",
              "public": "BASE64URL_32_BYTE_ED25519_PUBLIC_KEY"
            }
          ]
        }
      },
      "rules": [
        {
          "id": "publisher-can-submit-and-sign",
          "subjects": ["content.publisher"],
          "action": ["op:decrypt", "op:sign"],
          "target": ["broker.request_encryption.2026q3", "publisher.signing.2026q3"]
        }
      ]
    }

    algorithm is ed25519 for base64url raw Ed25519 public keys or nats-nkey for public NATS NKeys. Malformed public material is rejected at policy load time. Grant only the broker request-encryption key and the specific operation keys the actor needs.

    Where to go next