From 9aab69d8bc0f4e201ce63cabe317702e285ec7a6 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Tue, 11 Aug 2026 05:41:26 +0100 Subject: [PATCH] feat(plc): add wf_plc_get_last_op and wf_plc_build_handle_update Fills the gap blocking a PDS from implementing real PLC handle updates (only genesis operations were previously supported end-to-end): - wf_plc_get_last_op fetches an account's currently published operation from the PLC directory (GET {plc_directory_url}/{did}/log/last) and computes its CID via the same canonical-CBOR path wf_plc_operation_compute_did already uses -- the value a caller must set as `prev` on the next operation. - wf_plc_build_handle_update wraps it for the common case: change only alsoKnownAs to the new handle, preserving rotationKeys, verificationMethods, and services from the current operation unchanged, then sign with the caller-supplied rotation key. Every PLC operation is a full snapshot, not a diff, so getting this merge wrong corrupts a live account's DID document. Both take a caller-owned wf_xrpc_client so tests can install wf_xrpc_set_handler and run offline, unlike wf_plc_submit_operation_raw's self-contained-client pattern. --- include/wolfram/plc.h | 44 ++++++++++ src/plc/plc.c | 200 ++++++++++++++++++++++++++++++++++++++++++ test/test_plc.c | 166 ++++++++++++++++++++++++++++++++++- 3 files changed, 409 insertions(+), 1 deletion(-) diff --git a/include/wolfram/plc.h b/include/wolfram/plc.h index e238023..d54a24a 100644 --- a/include/wolfram/plc.h +++ b/include/wolfram/plc.h @@ -168,6 +168,50 @@ wf_status wf_plc_submit_operation_raw(const char *plc_directory_url, const char *did, const char *signed_op_json); +/** + * Fetch the account's currently published PLC operation from the directory + * (GET {plc_directory_url}/{did}/log/last) and compute its CID -- the value + * a caller must set as `prev` when building the account's next operation. + * Every PLC operation is a full snapshot, not a diff, so *out_op_json is + * also returned: a caller building an update must copy forward whichever of + * rotationKeys/verificationMethods/services/alsoKnownAs it does not intend + * to change. + * + * `client` is caller-owned (not freed here) so a test can supply one with + * wf_xrpc_set_handler installed, and so a real caller can reuse a client + * with whatever TLS/proxy config it already has rather than getting a + * bare default one under the hood. + * + * On WF_OK, *out_cid and *out_op_json are heap-allocated and owned by the + * caller; free both with plain free(). + */ +wf_status wf_plc_get_last_op(wf_xrpc_client *client, + const char *plc_directory_url, const char *did, + char **out_cid, char **out_op_json); + +/** + * Build, sign, and return a PLC operation that changes only `alsoKnownAs` to + * ["at://" + new_handle], preserving every other field (rotationKeys, + * verificationMethods, services) from the account's currently published + * operation. Internally calls wf_plc_get_last_op to fetch that operation and + * its CID (used as `prev`), so the caller does not need to track either. + * + * The submitted result is NOT verified against the caller-supplied + * `rotation_key` before signing; the caller is responsible for using the + * account's actual current PLC rotation key, since publishing a genesis-vs- + * update mismatch or a stale `prev` will be rejected by the directory, not + * caught here. + * + * `client` is caller-owned; see wf_plc_get_last_op. + * + * On WF_OK, *out_signed_json is caller-owned; free with wf_plc_operation_free. + */ +wf_status wf_plc_build_handle_update(wf_xrpc_client *client, + const char *plc_directory_url, + const char *did, const char *new_handle, + const wf_signing_key *rotation_key, + char **out_signed_json); + #ifdef __cplusplus } #endif diff --git a/src/plc/plc.c b/src/plc/plc.c index 9925b85..32ae790 100644 --- a/src/plc/plc.c +++ b/src/plc/plc.c @@ -16,6 +16,7 @@ #include "wolfram/identity.h" #include "wolfram/log.h" #include "wolfram/repo/cbor.h" +#include "wolfram/repo/cid.h" #include "librb64u.h" /* ── small utilities ────────────────────────────────────────── */ @@ -895,3 +896,202 @@ wf_status wf_plc_submit_operation_raw(const char *plc_directory_url, } return status; } + +wf_status wf_plc_get_last_op(wf_xrpc_client *client, + const char *plc_directory_url, const char *did, + char **out_cid, char **out_op_json) { + wf_response response = {0}; + char url[1024]; + wf_status status; + cJSON *root = NULL; + unsigned char *cbor = NULL; + size_t cbor_len = 0; + wf_cid cid; + char *cid_str = NULL; + + if (!client || !plc_directory_url || !did || !out_cid || !out_op_json) + return WF_ERR_INVALID_ARG; + *out_cid = NULL; + *out_op_json = NULL; + + size_t base_len = strlen(plc_directory_url); + size_t did_len = strlen(did); + static const char suffix[] = "/log/last"; + if (base_len + 1 + did_len + sizeof(suffix) >= sizeof(url)) + return WF_ERR_INVALID_ARG; + memcpy(url, plc_directory_url, base_len); + url[base_len] = '/'; + memcpy(url + base_len + 1, did, did_len); + memcpy(url + base_len + 1 + did_len, suffix, sizeof(suffix)); + + status = wf_http_get(client, url, &response); + if (status != WF_OK) { + /* WF_ERR_HTTP still transfers the body into `response` (see + * xrpc.c's transfer contract), so free it on every non-WF_OK + * status, not just the happy path. */ + free(response.body); + return status; + } + + root = cJSON_ParseWithLength(response.body, response.body_len); + if (!root || !cJSON_IsObject(root)) { + cJSON_Delete(root); + free(response.body); + return WF_ERR_PARSE; + } + + if (wf_plc_canonical_cbor(root, NULL, &cbor, &cbor_len) != WF_OK) { + cJSON_Delete(root); + free(response.body); + return WF_ERR_INTERNAL; + } + cJSON_Delete(root); + + memset(&cid, 0, sizeof(cid)); + status = wf_cid_of_block(cbor, cbor_len, &cid); + free(cbor); + if (status != WF_OK) { + free(response.body); + return status; + } + + cid_str = wf_cid_to_string(&cid); + if (!cid_str) { + free(response.body); + return WF_ERR_ALLOC; + } + + /* Own the operation JSON as a NUL-terminated string separate from + * `response`, whose body is not guaranteed to be NUL-terminated. */ + char *op_json = malloc(response.body_len + 1); + if (!op_json) { + free(cid_str); + free(response.body); + return WF_ERR_ALLOC; + } + memcpy(op_json, response.body, response.body_len); + op_json[response.body_len] = '\0'; + free(response.body); + + *out_cid = cid_str; + *out_op_json = op_json; + return WF_OK; +} + +/* Copy every string in a cJSON array of strings into a freshly allocated + * const char** (each entry heap-owned), for handing to + * wf_plc_operation_update's array-of-C-string fields. Non-string entries + * are skipped. `*out_count` reflects however many were actually copied. */ +static wf_status plc_string_array_from_json(const cJSON *arr, char ***out_strs, + size_t *out_count) { + *out_strs = NULL; + *out_count = 0; + if (!arr || !cJSON_IsArray(arr)) return WF_OK; + + size_t cap = (size_t)cJSON_GetArraySize(arr); + if (cap == 0) return WF_OK; + char **strs = calloc(cap, sizeof(*strs)); + if (!strs) return WF_ERR_ALLOC; + + size_t count = 0; + const cJSON *item = NULL; + cJSON_ArrayForEach(item, arr) { + if (!cJSON_IsString(item)) continue; + strs[count] = strdup(item->valuestring); + if (!strs[count]) { + for (size_t i = 0; i < count; i++) free(strs[i]); + free(strs); + return WF_ERR_ALLOC; + } + count++; + } + *out_strs = strs; + *out_count = count; + return WF_OK; +} + +static void plc_string_array_free(char **strs, size_t count) { + for (size_t i = 0; i < count; i++) free(strs[i]); + free(strs); +} + +wf_status wf_plc_build_handle_update(wf_xrpc_client *client, + const char *plc_directory_url, + const char *did, const char *new_handle, + const wf_signing_key *rotation_key, + char **out_signed_json) { + if (!client || !plc_directory_url || !did || !new_handle || !rotation_key || + !out_signed_json) + return WF_ERR_INVALID_ARG; + *out_signed_json = NULL; + + char *prev_cid = NULL; + char *last_op_json = NULL; + wf_status status = wf_plc_get_last_op(client, plc_directory_url, did, + &prev_cid, &last_op_json); + if (status != WF_OK) return status; + + cJSON *last_op = cJSON_Parse(last_op_json); + free(last_op_json); + if (!last_op || !cJSON_IsObject(last_op)) { + cJSON_Delete(last_op); + free(prev_cid); + return WF_ERR_PARSE; + } + + char **rotation_keys = NULL; + size_t rotation_keys_count = 0; + status = plc_string_array_from_json( + cJSON_GetObjectItemCaseSensitive(last_op, "rotationKeys"), + &rotation_keys, &rotation_keys_count); + if (status != WF_OK) { + cJSON_Delete(last_op); + free(prev_cid); + return status; + } + + cJSON *verification_methods = + cJSON_GetObjectItemCaseSensitive(last_op, "verificationMethods"); + char *verification_methods_json = + verification_methods ? cJSON_PrintUnformatted(verification_methods) + : NULL; + cJSON *services = cJSON_GetObjectItemCaseSensitive(last_op, "services"); + char *services_json = services ? cJSON_PrintUnformatted(services) : NULL; + cJSON_Delete(last_op); + + size_t handle_len = strlen(new_handle); + char *aka = malloc(strlen("at://") + handle_len + 1); + if (!aka) { + plc_string_array_free(rotation_keys, rotation_keys_count); + free(verification_methods_json); + free(services_json); + free(prev_cid); + return WF_ERR_ALLOC; + } + snprintf(aka, strlen("at://") + handle_len + 1, "at://%s", new_handle); + + const char *aka_arr[1] = {aka}; + wf_plc_operation_update update = { + .rotation_keys = (const char *const *)rotation_keys, + .rotation_keys_count = rotation_keys_count, + .verification_methods_json = verification_methods_json, + .services_json = services_json, + .also_known_as = aka_arr, + .also_known_as_count = 1, + .prev = prev_cid, + }; + + char *unsigned_json = NULL; + status = wf_plc_operation_build(&update, &unsigned_json); + free(aka); + plc_string_array_free(rotation_keys, rotation_keys_count); + free(verification_methods_json); + free(services_json); + free(prev_cid); + if (status != WF_OK) return status; + + status = + wf_plc_operation_sign(unsigned_json, rotation_key, out_signed_json); + wf_plc_operation_free(unsigned_json); + return status; +} diff --git a/test/test_plc.c b/test/test_plc.c index 0b888f1..1c9f918 100644 --- a/test/test_plc.c +++ b/test/test_plc.c @@ -3,7 +3,9 @@ * * No network: exercises wf_plc_operation_build, wf_plc_operation_sign and * wf_plc_operation_verify round-tripping through the crypto primitives, plus - * a did:key derivation check. + * a did:key derivation check. wf_plc_get_last_op / wf_plc_build_handle_update + * are exercised via wf_xrpc_set_handler's offline test seam, same as the + * identity resolver tests -- no real network I/O. */ #include "wolfram/plc.h" @@ -15,6 +17,8 @@ #include #include "wolfram/crypto.h" +#include "wolfram/repo/cid.h" +#include "wolfram/xrpc.h" static int failures = 0; @@ -127,12 +131,172 @@ static int build_and_sign_roundtrip(wf_key_type key_type) { return rc; } +/* Canned "current operation" served by log_last_handler below. Set by the + * test before installing the handler -- its rotationKeys must actually name + * the key the test signs the handle-update op with, or verification will + * (correctly) reject a signer that the op itself doesn't list. */ +static char *g_canned_last_op = NULL; + +/* Test seam handler (see wf_xrpc_set_handler): serves the canned operation + * above for any GET, so wf_plc_get_last_op / wf_plc_build_handle_update can + * be exercised without a real PLC directory. */ +static wf_status log_last_handler(void *userdata, const char *method, + const char *url, const char *content_type, + const char *body, size_t body_len, + const wf_http_header *headers, + size_t header_count, wf_response *out) { + (void)userdata; + (void)method; + (void)url; + (void)content_type; + (void)body; + (void)body_len; + (void)headers; + (void)header_count; + + size_t len = strlen(g_canned_last_op); + out->body = malloc(len + 1); + if (!out->body) return WF_ERR_ALLOC; + memcpy(out->body, g_canned_last_op, len + 1); + out->body_len = len; + out->status = 200; + return WF_OK; +} + +static void test_get_last_op_and_build_handle_update(void) { + wf_xrpc_client *client = wf_xrpc_client_new("https://plc.example.invalid"); + CHECK(client != NULL, "wf_xrpc_client_new"); + if (!client) return; + + /* The rotation key must be generated before the canned op is built: the + * canned op's rotationKeys must actually name it, or a real + * wf_plc_operation_verify (correctly) rejects a signer the op doesn't + * list -- this is not a fake for the test's sake, it is how PLC + * verification is supposed to work. */ + wf_signing_key rotation_key; + CHECK(wf_signing_key_generate(WF_KEY_TYPE_P256, &rotation_key) == WF_OK, + "wf_signing_key_generate (handle update)"); + char *rotation_didkey = NULL; + CHECK(wf_signing_key_public_didkey(&rotation_key, &rotation_didkey) == + WF_OK, + "wf_signing_key_public_didkey (handle update)"); + if (!rotation_didkey) { + wf_xrpc_client_free(client); + return; + } + + char canned[1024]; + snprintf(canned, sizeof(canned), + "{\"type\":\"plc_operation\",\"rotationKeys\":[\"%s\"]," + "\"verificationMethods\":{\"atproto\":\"did:key:" + "zQ3shExampleAtprotoKey\"},\"services\":{\"atproto_pds\":{" + "\"type\":\"AtprotoPersonalDataServer\",\"endpoint\":\"https://" + "old.example.com\"}},\"alsoKnownAs\":[\"at://old-handle." + "example\"],\"prev\":null,\"sig\":\"fakeSigValueForTestingOnly\"}", + rotation_didkey); + g_canned_last_op = canned; + wf_xrpc_set_handler(client, log_last_handler, NULL); + + char *cid = NULL; + char *op_json = NULL; + wf_status status = + wf_plc_get_last_op(client, "https://plc.example.invalid", + "did:plc:testaccount123456789", &cid, &op_json); + CHECK(status == WF_OK, "wf_plc_get_last_op"); + CHECK(cid != NULL && cid[0] != '\0', "wf_plc_get_last_op returns a CID"); + if (cid) { + wf_cid parsed; + CHECK(wf_cid_from_string(cid, &parsed) == WF_OK, + "returned CID string parses as a real CID"); + } + CHECK(op_json != NULL && strcmp(op_json, g_canned_last_op) == 0, + "wf_plc_get_last_op returns the served operation verbatim"); + free(cid); + free(op_json); + + char *signed_json = NULL; + status = wf_plc_build_handle_update( + client, "https://plc.example.invalid", "did:plc:testaccount123456789", + "new-handle.example", &rotation_key, &signed_json); + CHECK(status == WF_OK, "wf_plc_build_handle_update"); + CHECK(signed_json != NULL, "wf_plc_build_handle_update output allocated"); + + if (signed_json) { + cJSON *op = cJSON_Parse(signed_json); + CHECK(op != NULL, "handle update op parses"); + if (op) { + /* Preserved from the canned last op, unchanged. */ + cJSON *rk = cJSON_GetObjectItemCaseSensitive(op, "rotationKeys"); + CHECK(cJSON_IsArray(rk) && cJSON_GetArraySize(rk) == 1, + "rotationKeys preserved (count)"); + if (cJSON_IsArray(rk) && cJSON_GetArraySize(rk) == 1) { + cJSON *first = cJSON_GetArrayItem(rk, 0); + CHECK(cJSON_IsString(first) && + strcmp(first->valuestring, rotation_didkey) == 0, + "rotationKeys preserved (value)"); + } + cJSON *vm = + cJSON_GetObjectItemCaseSensitive(op, "verificationMethods"); + cJSON *vm_atproto = + vm ? cJSON_GetObjectItemCaseSensitive(vm, "atproto") : NULL; + CHECK(cJSON_IsString(vm_atproto) && + strcmp(vm_atproto->valuestring, + "did:key:zQ3shExampleAtprotoKey") == 0, + "verificationMethods preserved"); + cJSON *services = cJSON_GetObjectItemCaseSensitive(op, "services"); + cJSON *pds = + services + ? cJSON_GetObjectItemCaseSensitive(services, "atproto_pds") + : NULL; + cJSON *endpoint = + pds ? cJSON_GetObjectItemCaseSensitive(pds, "endpoint") : NULL; + CHECK(cJSON_IsString(endpoint) && + strcmp(endpoint->valuestring, + "https://old.example.com") == 0, + "services preserved"); + + /* Changed: alsoKnownAs, and prev now points at the canned op. */ + cJSON *aka = cJSON_GetObjectItemCaseSensitive(op, "alsoKnownAs"); + CHECK(cJSON_IsArray(aka) && cJSON_GetArraySize(aka) == 1, + "alsoKnownAs has exactly the new handle"); + if (cJSON_IsArray(aka) && cJSON_GetArraySize(aka) == 1) { + cJSON *first = cJSON_GetArrayItem(aka, 0); + CHECK(cJSON_IsString(first) && + strcmp(first->valuestring, + "at://new-handle.example") == 0, + "alsoKnownAs is at://new-handle.example"); + } + cJSON *prev = cJSON_GetObjectItemCaseSensitive(op, "prev"); + CHECK(cJSON_IsString(prev) && prev->valuestring[0] != '\0', + "prev is set to the fetched last-op CID"); + + /* Signed with the given rotation key, and verifiable. */ + char *verify_didkey = NULL; + wf_status vstatus = + wf_plc_operation_verify(signed_json, &verify_didkey); + CHECK(vstatus == WF_OK, "handle update op verifies"); + if (verify_didkey && rotation_didkey) { + CHECK(strcmp(verify_didkey, rotation_didkey) == 0, + "handle update op signed by the given rotation key"); + } + free(verify_didkey); + } + cJSON_Delete(op); + } + + free(rotation_didkey); + wf_plc_operation_free(signed_json); + wf_xrpc_client_free(client); + g_canned_last_op = NULL; /* was pointing at this function's stack buffer */ +} + int main(void) { /* P-256 is always available (OpenSSL). */ build_and_sign_roundtrip(WF_KEY_TYPE_P256); #ifdef HAVE_LIBSECP256K1 build_and_sign_roundtrip(WF_KEY_TYPE_SECP256K1); #endif + test_get_last_op_and_build_handle_update(); if (failures == 0) { printf("plc: all tests passed\n"); return 0; -- 2.51.2