So, the jose library needs a little work done to separate the ES (Ephemeral-Static) and SS (Static-Static) operations.
The JWA RFC 7518 only defines ES operations and actually states (https://tools.ietf.org/html/rfc7518#section-4.6):
A new ephemeral public key value MUST be generated for each key agreement operation.
The separate modes of operation were clarified by a different working group, COSE WG, in RFC 8152 (https://tools.ietf.org/html/rfc8152#section-12.4):
Ephemeral-Static (ES) DH: where the sender of the message creates a one-time DH key and uses a static key for the recipient. The use of the ephemeral sender key means that no additional random input is needed as this is randomly generated for each message.
Static-Static DH: where a static key is used for both the sender and the recipient. The use of static keys allows for the recipient to get a weak version of data origination for the message. When static-static key agreement is used, then some piece of unique data for the KDF is required to ensure that a different key is created for each message.
Since JOSE only defined the ES operations, all ECDH-ES operations require the "epk" protected header. For decryption, there are actually two methods of operation: passing the ephemeral public + static secret as a tuple, or simply passing the static secret (since the ephemeral public is already embedded). If both keys are passed as a tuple, we first check to make sure that both the embedded ephemeral public and the given ephemeral public both have the same fingerprint before continuing.
This keeps it in line with the other public key encryption operations (like RSA1_5, RSA-OAEP, and RSA-OAEP-256, where the sender using the public key is unable to decrypt a message they have just encrypted).
This check can technically be bypassed manually:
expanded = JOSE.JWE.expand(server_to_client_encrypted) |> elem(1)
protected = expanded |> Map.fetch!("protected") |> :base64url.decode() |> JOSE.decode()
# first, let's decrypt the content encryption key (CEK)
z = JOSE.JWK.shared_secret(client_public_key, server_secret_key)
algorithm = "ECDH-ES+A128KW"
key_data_len = 128
supp_pub_info = << key_data_len :: unsigned-big-integer-unit(32)-size(1) >>
apu = protected |> Map.get("apu", <<>>) |> :base64url.decode()
apv = protected |> Map.get("apv", <<>>) |> :base64url.decode()
kek = :jose_jwa_concat_kdf.kdf(:sha256, z, {algorithm, apu, apv, supp_pub_info}, key_data_len)
encrypted_key = expanded |> Map.fetch!("encrypted_key") |> :base64url.decode()
cek = :jose_jwa_aes_kw.unwrap(encrypted_key, kek)
# second, let's decrypt the ciphertext
iv = expanded |> Map.fetch!("iv") |> :base64url.decode()
ciphertext = expanded |> Map.fetch!("ciphertext") |> :base64url.decode()
ciphertag = expanded |> Map.fetch!("tag") |> :base64url.decode()
aad = expanded |> Map.fetch!("protected")
{enc_module, enc} = JOSE.JWE.from(protected).enc
plaintext = enc_module.block_decrypt({aad, ciphertext, ciphertag}, cek, iv, enc)Although there is no formal JOSE standard for ECDH-SS, I think it should probably be added to the jose library (as well as the other algorithms defined by COSE). This would allow for encryption/decryption without involving a ephemeral key.