diff --git a/README.md b/README.md index a869daa..3111de0 100644 --- a/README.md +++ b/README.md @@ -464,6 +464,18 @@ The high-level API covers operations including: The generic `invoke()` API remains available for compatible QKMS operations that do not yet have a dedicated facade function. +For MPC-backed key creation, prefer `create_key_async()` when the ceremony can outlive an HTTP proxy/CDN timeout. It sends the QKMS-compatible `CreateKey` request with the signed `?async=1` query so QKMS can acknowledge the task immediately while DKG continues asynchronously: + +```cpp +const auto created = quilibrium::sync_wait( + sdk->kms().create_key_async( + R"({"KeySpec":"ECC_SECG_P256K1","KeyUsage":"SIGN_VERIFY"})" + ) +); +``` + +This SDK intentionally covers the signed QKMS HTTP/KMS surface. The QNZM login flow and participant-side MPC sidecar/ceremony orchestration are a separate concern; applications that need browser/server sidecars participating in DKG should use the official Quilibrium QKMS SDK for that layer. + --- # Native Quilibrium Protocol diff --git a/docs/SERVICE_MATRIX.md b/docs/SERVICE_MATRIX.md index f872553..48fe11e 100644 --- a/docs/SERVICE_MATRIX.md +++ b/docs/SERVICE_MATRIX.md @@ -6,7 +6,7 @@ | HyperSnap casts | `quilibrium.sdk`, `quilibrium.hypersnap` | Typed get/search, conversation JSON + raw API | Farcaster writes use signed protocol messages | | HyperSnap feeds | `quilibrium.sdk`, `quilibrium.hypersnap` | Following/trending/user-casts typed pages | Additional documented paths remain available through raw GET | | QStorage | `quilibrium.sdk`, `quilibrium.qstorage` | Header SigV4, presigned PUT/GET/HEAD URLs, signed-header contracts, bucket/object common ops, copy/head/list, multipart create/upload/complete/abort/list, raw execute/presign | XML is returned raw; full presigned multipart orchestration can build on generic arbitrary-target presigning | -| QKMS | `quilibrium.sdk`, `quilibrium.qkms` | SigV4 generic invoke + named key/crypto/data-key/MAC/import/policy/alias/grant/tag/rotation/replication/deletion methods | QNZM/MPC sidecar login/session workflow is separate from the KMS-compatible API | +| QKMS | `quilibrium.sdk`, `quilibrium.qkms` | SigV4 generic invoke + named key/crypto/data-key/MAC/import/policy/alias/grant/tag/rotation/replication/deletion methods + async `CreateKey` (`?async=1`) | QNZM/MPC sidecar login/session and participant ceremony workflow are separate from the KMS-compatible API | | NodeService | `quilibrium.sdk`, `quilibrium.protocol` | Unary raw-protobuf call + complete registry | Typed generated protobuf wrapper not bundled | | ConnectivityService | same | Unary raw-protobuf + registry | — | | GlobalService | same | Unary methods callable; stream method registered | Server streaming execution not implemented in v1.1 | diff --git a/src/qkms.cpp b/src/qkms.cpp index 16bd8e1..61eae77 100644 --- a/src/qkms.cpp +++ b/src/qkms.cpp @@ -27,15 +27,16 @@ client::~client()=default; client::client(client&&) noexcept=default; client& client::operator=(client&&) noexcept=default; -task> client::invoke(std::string operation,bytes payload,call_options options) { +task> client::invoke(std::string operation,bytes payload,call_options options,std::string query) { if(!impl_->transport) co_return std::unexpected(error{.domain=error_domain::configuration,.code=400,.message="HTTP transport is not configured"}); if(impl_->endpoints.empty()) co_return std::unexpected(error{.domain=error_domain::configuration,.code=401,.message="QKMS endpoint list is empty"}); const auto attempts = std::max(1U, options.max_attempts); error last_error{.domain=error_domain::transport,.code=402,.message="QKMS request failed",.retryable=true}; + const std::string target=query.empty()?"/":"/?"+query; for(std::uint32_t attempt=0; attemptendpoints[attempt % impl_->endpoints.size()]; - http_request request{.verb=http_method::post,.target_endpoint=selected,.target="/",.header_fields={{"content-type","application/x-amz-json-1.1"},{"x-amz-target",impl_->target_prefix+"."+operation}},.body=payload}; + http_request request{.verb=http_method::post,.target_endpoint=selected,.target=target,.header_fields={{"content-type","application/json"},{"x-amz-target",impl_->target_prefix+"."+operation}},.body=payload}; if(auto status=impl_->signer.sign(request);!status) co_return std::unexpected(status.error()); auto raw=impl_->transport->send_now(std::move(request),options); if(!raw) { @@ -53,7 +54,8 @@ task> client::invoke(std::string operation,bytes payload,call_o } #define QL_KMS_FORWARD(name,operation) task> client::name(bytes p,call_options o){co_return sync_wait(invoke(operation,std::move(p),o));} -QL_KMS_FORWARD(create_key,"CreateKey") +task> client::create_key(bytes p,call_options o){o.idempotent=false;co_return sync_wait(invoke("CreateKey",std::move(p),o));} +task> client::create_key_async(bytes p,call_options o){o.idempotent=false;co_return sync_wait(invoke("CreateKey",std::move(p),o,"async=1"));} QL_KMS_FORWARD(describe_key,"DescribeKey") QL_KMS_FORWARD(enable_key,"EnableKey") QL_KMS_FORWARD(disable_key,"DisableKey") diff --git a/src/qkms.cppm b/src/qkms.cppm index 409cd71..bc2f8cb 100644 --- a/src/qkms.cppm +++ b/src/qkms.cppm @@ -15,8 +15,12 @@ class client final { public: client(config configuration, http_transport_ptr transport); ~client(); client(client&&) noexcept; client& operator=(client&&) noexcept; client(const client&)=delete; client& operator=(const client&)=delete; - [[nodiscard]] task> invoke(std::string operation, bytes json_payload, call_options options = {}); + /** Invokes a QKMS operation. `query` is the raw query string without a leading `?` and is included in SigV4 canonicalization. */ + [[nodiscard]] task> invoke(std::string operation, bytes json_payload, call_options options = {}, std::string query = {}); + /** Blocking CreateKey; waits for QKMS to complete the MPC operation. */ [[nodiscard]] task> create_key(bytes payload, call_options options = {}); + /** Non-blocking CreateKey (`?async=1`); avoids proxy/CDN 504s while MPC/DKG continues asynchronously. */ + [[nodiscard]] task> create_key_async(bytes payload, call_options options = {}); [[nodiscard]] task> describe_key(bytes payload, call_options options = {}); [[nodiscard]] task> list_keys(bytes payload = {}, call_options options = {}); [[nodiscard]] task> enable_key(bytes payload, call_options options = {}); diff --git a/src/sdk_facade.cpp b/src/sdk_facade.cpp index 26daa8b..09a20a0 100644 --- a/src/sdk_facade.cpp +++ b/src/sdk_facade.cpp @@ -636,13 +636,32 @@ task> kms_api::invoke(std::string operation,std::string if (!state) co_return std::unexpected(error{.domain=error_domain::configuration,.code=850,.message="SDK state is unavailable"}); auto view=as_bytes(json_payload); bytes payload(view.begin(),view.end()); - auto response=send_with_failover(*state,state->kms,http_method::post,"/",{{"content-type","application/x-amz-json-1.1"},{"x-amz-target","TrentService."+operation}},std::move(payload),options,&state->kms_credentials,state->kms_region,"kms"); + auto response=send_with_failover(*state,state->kms,http_method::post,"/",{{"content-type","application/json"},{"x-amz-target","TrentService."+operation}},std::move(payload),options,&state->kms_credentials,state->kms_region,"kms"); if (!response) co_return std::unexpected(response.error()); co_return convert(std::move(*response)); } #define QL_SDK_KMS_FORWARD(name,operation) task> kms_api::name(std::string payload,call_options options) const { co_return sync_wait(invoke(operation,std::move(payload),options)); } -QL_SDK_KMS_FORWARD(create_key,"CreateKey") +task> kms_api::create_key(std::string json_payload,call_options options) const { + auto* state=state_cast(state_); + if (!state) co_return std::unexpected(error{.domain=error_domain::configuration,.code=850,.message="SDK state is unavailable"}); + options.idempotent=false; + auto view=as_bytes(json_payload); + bytes payload(view.begin(),view.end()); + auto response=send_with_failover(*state,state->kms,http_method::post,"/",{{"content-type","application/json"},{"x-amz-target","TrentService.CreateKey"}},std::move(payload),options,&state->kms_credentials,state->kms_region,"kms"); + if (!response) co_return std::unexpected(response.error()); + co_return convert(std::move(*response)); +} +task> kms_api::create_key_async(std::string json_payload,call_options options) const { + auto* state=state_cast(state_); + if (!state) co_return std::unexpected(error{.domain=error_domain::configuration,.code=850,.message="SDK state is unavailable"}); + options.idempotent=false; + auto view=as_bytes(json_payload); + bytes payload(view.begin(),view.end()); + auto response=send_with_failover(*state,state->kms,http_method::post,"/?async=1",{{"content-type","application/json"},{"x-amz-target","TrentService.CreateKey"}},std::move(payload),options,&state->kms_credentials,state->kms_region,"kms"); + if (!response) co_return std::unexpected(response.error()); + co_return convert(std::move(*response)); +} QL_SDK_KMS_FORWARD(describe_key,"DescribeKey") QL_SDK_KMS_FORWARD(encrypt,"Encrypt") QL_SDK_KMS_FORWARD(decrypt,"Decrypt") diff --git a/src/sdk_facade.cppm b/src/sdk_facade.cppm index caf89c9..5bfb3e0 100644 --- a/src/sdk_facade.cppm +++ b/src/sdk_facade.cppm @@ -195,6 +195,8 @@ public: /** Invokes any compatible `TrentService.` QKMS operation. */ [[nodiscard]] task> invoke(std::string operation,std::string json_payload="{}",call_options options={}) const; [[nodiscard]] task> create_key(std::string json_payload="{}",call_options options={}) const; + /** Non-blocking CreateKey (`?async=1`) for MPC/DKG operations that may outlive proxy timeouts. */ + [[nodiscard]] task> create_key_async(std::string json_payload="{}",call_options options={}) const; [[nodiscard]] task> describe_key(std::string json_payload,call_options options={}) const; [[nodiscard]] task> encrypt(std::string json_payload,call_options options={}) const; [[nodiscard]] task> decrypt(std::string json_payload,call_options options={}) const; diff --git a/tests/c_api_tests.c b/tests/c_api_tests.c index a7eaf39..c8732b1 100644 --- a/tests/c_api_tests.c +++ b/tests/c_api_tests.c @@ -3,7 +3,7 @@ #include int main(void) { - assert(strcmp(ql_version(),"1.1.0")==0); + assert(strcmp(ql_version(),"1.2.0")==0); ql_error error={0}; ql_sdk* sdk=ql_sdk_create(NULL,&error); assert(sdk!=NULL); diff --git a/tests/facade_tests.cpp b/tests/facade_tests.cpp index bdb8619..bd972ff 100644 --- a/tests/facade_tests.cpp +++ b/tests/facade_tests.cpp @@ -9,10 +9,13 @@ import quilibrium.sdk; struct mock_state final { bool saw_storage_auth{false}; bool saw_kms_auth{false}; + bool saw_kms_async_create{false}; + bool saw_kms_json_content_type{false}; + bool saw_kms_create_non_idempotent{false}; bool saw_native_grpc{false}; }; -quilibrium::result mock_send(void* opaque,quilibrium::http_request request,quilibrium::call_options) { +quilibrium::result mock_send(void* opaque,quilibrium::http_request request,quilibrium::call_options options) { auto* state=static_cast(opaque); std::string body; quilibrium::http_headers headers{{"content-type","application/json"}}; @@ -24,8 +27,13 @@ quilibrium::result mock_send(void* opaque,quilibrium: } else if(request.target=="/bucket/object.bin") { state->saw_storage_auth=request.header_fields.contains("authorization"); body="storage-ok"; - } else if(request.target=="/"&&request.header_fields.contains("x-amz-target")) { + } else if((request.target=="/"||request.target=="/?async=1")&&request.header_fields.contains("x-amz-target")) { state->saw_kms_auth=request.header_fields.contains("authorization"); + if(request.header_fields.at("x-amz-target")=="TrentService.CreateKey") { + if(request.target=="/?async=1") state->saw_kms_async_create=true; + if(!options.idempotent) state->saw_kms_create_non_idempotent=true; + } + if(const auto ct=request.header_fields.find("content-type");ct!=request.header_fields.end()&&ct->second=="application/json") state->saw_kms_json_content_type=true; body=R"({"Signature":"AA=="})"; } else if(request.target=="/quilibrium.node.node.pb.NodeService/GetNodeInfo") { state->saw_native_grpc=true; @@ -63,6 +71,10 @@ int main(){ auto signed_value=quilibrium::sync_wait(q->kms().invoke("Sign",R"({"KeyId":"test"})")); assert(signed_value&&signed_value->status_code==200&&state->saw_kms_auth); + auto blocking_key=quilibrium::sync_wait(q->kms().create_key(R"({"KeySpec":"ECC_SECG_P256K1"})")); + assert(blocking_key&&blocking_key->status_code==200&&state->saw_kms_create_non_idempotent); + auto async_key=quilibrium::sync_wait(q->kms().create_key_async(R"({"KeySpec":"ECC_SECG_P256K1"})")); + assert(async_key&&async_key->status_code==200&&state->saw_kms_async_create&&state->saw_kms_json_content_type&&state->saw_kms_create_non_idempotent); auto node_info=quilibrium::sync_wait(q->native().call(quilibrium::native_service::node,"GetNodeInfo",{})); assert(node_info&&node_info->size()==2&&state->saw_native_grpc); diff --git a/tests/service_tests.cpp b/tests/service_tests.cpp index eb61ef6..d4582bd 100644 --- a/tests/service_tests.cpp +++ b/tests/service_tests.cpp @@ -15,15 +15,23 @@ struct mock_state final { bool saw_kms_target{false}; bool saw_multipart{false}; bool saw_rotation_target{false}; + bool saw_async_create{false}; + bool saw_qkms_json_content_type{false}; + bool saw_create_non_idempotent{false}; }; -quilibrium::result service_send(void* opaque,quilibrium::http_request request,quilibrium::call_options) { +quilibrium::result service_send(void* opaque,quilibrium::http_request request,quilibrium::call_options options) { auto* state=static_cast(opaque); if (request.header_fields.contains("authorization")) state->saw_authorization=true; if (request.target.find("?uploads") != std::string::npos) state->saw_multipart=true; if (const auto it=request.header_fields.find("x-amz-target");it!=request.header_fields.end()) { if (it->second=="TrentService.Sign") state->saw_kms_target=true; if (it->second=="TrentService.EnableKeyRotation") state->saw_rotation_target=true; + if (it->second=="TrentService.CreateKey") { + if (request.target=="/?async=1") state->saw_async_create=true; + if (!options.idempotent) state->saw_create_non_idempotent=true; + } + if (const auto ct=request.header_fields.find("content-type");ct!=request.header_fields.end() && ct->second=="application/json") state->saw_qkms_json_content_type=true; } std::string body=request.target.starts_with("/v2/farcaster/user")?R"({"user":{"fid":3,"username":"dwr.eth"}})":"{}"; const auto view=quilibrium::as_bytes(body); @@ -66,8 +74,15 @@ int main() { assert(response&&response->status_code==200); auto rotation=quilibrium::sync_wait(client.enable_key_rotation({})); assert(rotation&&rotation->status_code==200); + auto blocking_create=quilibrium::sync_wait(client.create_key({})); + assert(blocking_create&&blocking_create->status_code==200); + auto async_create=quilibrium::sync_wait(client.create_key_async({})); + assert(async_create&&async_create->status_code==200); assert(state->saw_kms_target); assert(state->saw_rotation_target); + assert(state->saw_async_create); + assert(state->saw_qkms_json_content_type); + assert(state->saw_create_non_idempotent); } {