From 9452cad358fc9f44ad19456ef1831b63f166e635 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:10:24 -0400 Subject: [PATCH 01/49] Define checked reader-owned PFD source admission contract --- native/android/ib_reader_pfd.h | 63 ++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 native/android/ib_reader_pfd.h diff --git a/native/android/ib_reader_pfd.h b/native/android/ib_reader_pfd.h new file mode 100644 index 00000000..4a832257 --- /dev/null +++ b/native/android/ib_reader_pfd.h @@ -0,0 +1,63 @@ +#ifndef IB_ANDROID_READER_PFD_H +#define IB_ANDROID_READER_PFD_H + +#include +#include +#include + +/* Reader accepts one complete bounded snapshot, never an Arena pathname. + * This C boundary consumes a descriptor received through Android Binder/PFD. + * Browser/navigation ownership remains with the checked Idric core. */ +#define IB_READER_SOURCE_MAX_BYTES (4U * 1024U * 1024U) +#define IB_READER_REQUEST_ID_CAP 64U + +typedef struct { + char request_id[IB_READER_REQUEST_ID_CAP + 1U]; + uint64_t navigation_generation; + uint32_t byte_count; + char sha256_hex[65]; +} ib_reader_source_ticket; + +typedef struct { + atomic_uint_fast64_t wanted_navigation_generation; + unsigned char *bytes; + size_t byte_count; + uint64_t published_navigation_generation; + char request_id[IB_READER_REQUEST_ID_CAP + 1U]; +} ib_reader_source; + +enum { + IB_READER_PFD_ACCEPTED = 0, + IB_READER_PFD_BAD_TICKET = 1, + IB_READER_PFD_STALE = 2, + IB_READER_PFD_IO = 3, + IB_READER_PFD_DIGEST = 4, + IB_READER_PFD_BINDER = 5 +}; + +/* Initialize once, use in a serialized reader-owned publication queue. + * Navigation may invalidate an in-flight read from another thread. + * Published bytes remain owned by the reader until a successful replacement. */ +void ib_reader_source_init(ib_reader_source *source); +void ib_reader_source_clear(ib_reader_source *source); +void ib_reader_source_want(ib_reader_source *source, uint64_t generation); + +/* Does NOT close fd. Requires a finite stream ending at exactly byte_count. + * Checks deadline, length, SHA-256 and generation before exposing any byte. */ +int ib_reader_source_accept_fd(ib_reader_source *source, + const ib_reader_source_ticket *ticket, + int fd); + +#if defined(__ANDROID__) && __ANDROID_API__ >= 29 +#include + +/* provider is an already authenticated private Binder capability, NOT an + * Android service-manager name or a Shizuku command. On transaction 1 it + * receives request_id and navigation_generation, then returns status=0 and + * a ParcelFileDescriptor of the already committed immutable source bytes. + * No untrusted URL, pathname, or body travels through a Parcel. */ +int ib_reader_source_fetch_binder(ib_reader_source *source, + const ib_reader_source_ticket *ticket, + AIBinder *provider); +#endif +#endif From 05134e57e9439163b834ee9afb65a308a1ac6a2d Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:11:21 -0400 Subject: [PATCH 02/49] Verify private Binder parcel descriptors before reader source publication --- native/android/ib_reader_pfd.c | 317 +++++++++++++++++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 native/android/ib_reader_pfd.c diff --git a/native/android/ib_reader_pfd.c b/native/android/ib_reader_pfd.c new file mode 100644 index 00000000..50e020bb --- /dev/null +++ b/native/android/ib_reader_pfd.c @@ -0,0 +1,317 @@ +#define _POSIX_C_SOURCE 200809L +#include "ib_reader_pfd.h" + +#include +#include +#include +#include +#include +#include +#include + +#define IB_PFD_READ_DEADLINE_MS 5000 + +typedef struct { + uint32_t word[8]; + unsigned char block[64]; + size_t used; + uint64_t processed_bits; +} sha256_state; + +static uint32_t rotate(uint32_t value, unsigned int count) { + return (value >> count) | (value << (32U - count)); +} + +static const uint32_t sha256_constants[64] = { + 0x428a2f98U, 0x71374491U, 0xb5c0fbcfU, 0xe9b5dba5U, + 0x3956c25bU, 0x59f111f1U, 0x923f82a4U, 0xab1c5ed5U, + 0xd807aa98U, 0x12835b01U, 0x243185beU, 0x550c7dc3U, + 0x72be5d74U, 0x80deb1feU, 0x9bdc06a7U, 0xc19bf174U, + 0xe49b69c1U, 0xefbe4786U, 0x0fc19dc6U, 0x240ca1ccU, + 0x2de92c6fU, 0x4a7484aaU, 0x5cb0a9dcU, 0x76f988daU, + 0x983e5152U, 0xa831c66dU, 0xb00327c8U, 0xbf597fc7U, + 0xc6e00bf3U, 0xd5a79147U, 0x06ca6351U, 0x14292967U, + 0x27b70a85U, 0x2e1b2138U, 0x4d2c6dfcU, 0x53380d13U, + 0x650a7354U, 0x766a0abbU, 0x81c2c92eU, 0x92722c85U, + 0xa2bfe8a1U, 0xa81a664bU, 0xc24b8b70U, 0xc76c51a3U, + 0xd192e819U, 0xd6990624U, 0xf40e3585U, 0x106aa070U, + 0x19a4c116U, 0x1e376c08U, 0x2748774cU, 0x34b0bcb5U, + 0x391c0cb3U, 0x4ed8aa4aU, 0x5b9cca4fU, 0x682e6ff3U, + 0x748f82eeU, 0x78a5636fU, 0x84c87814U, 0x8cc70208U, + 0x90befffaU, 0xa4506cebU, 0xbef9a3f7U, 0xc67178f2U +}; + +static void sha256_transform(sha256_state *state, + const unsigned char input[64]) { + uint32_t words[64], registers[8]; + for (size_t index = 0; index < 16; ++index) { + size_t at = index * 4U; + words[index] = ((uint32_t)input[at] << 24U) | + ((uint32_t)input[at + 1U] << 16U) | + ((uint32_t)input[at + 2U] << 8U) | + (uint32_t)input[at + 3U]; + } + for (size_t index = 16; index < 64; ++index) { + uint32_t a = words[index - 15U], b = words[index - 2U]; + uint32_t s0 = rotate(a, 7U) ^ rotate(a, 18U) ^ (a >> 3U); + uint32_t s1 = rotate(b, 17U) ^ rotate(b, 19U) ^ (b >> 10U); + words[index] = words[index - 16U] + s0 + + words[index - 7U] + s1; + } + memcpy(registers, state->word, sizeof(registers)); + for (size_t index = 0; index < 64; ++index) { + uint32_t s1 = rotate(registers[4], 6U) ^ + rotate(registers[4], 11U) ^ + rotate(registers[4], 25U); + uint32_t choose = (registers[4] & registers[5]) ^ + (~registers[4] & registers[6]); + uint32_t first = registers[7] + s1 + choose + + sha256_constants[index] + words[index]; + uint32_t s0 = rotate(registers[0], 2U) ^ + rotate(registers[0], 13U) ^ + rotate(registers[0], 22U); + uint32_t majority = (registers[0] & registers[1]) ^ + (registers[0] & registers[2]) ^ + (registers[1] & registers[2]); + uint32_t second = s0 + majority; + for (size_t at = 7; at > 0; --at) registers[at] = registers[at - 1U]; + registers[4] += first; + registers[0] = first + second; + } + for (size_t index = 0; index < 8; ++index) + state->word[index] += registers[index]; +} + +static void sha256_init(sha256_state *state) { + static const uint32_t initial[8] = { + 0x6a09e667U, 0xbb67ae85U, 0x3c6ef372U, 0xa54ff53aU, + 0x510e527fU, 0x9b05688cU, 0x1f83d9abU, 0x5be0cd19U + }; + memcpy(state->word, initial, sizeof(initial)); + state->used = 0; + state->processed_bits = 0; +} + +static void sha256_update(sha256_state *state, const unsigned char *body, + size_t count) { + for (size_t index = 0; index < count; ++index) { + state->block[state->used++] = body[index]; + if (state->used == sizeof(state->block)) { + sha256_transform(state, state->block); + state->processed_bits += 512U; + state->used = 0; + } + } +} + +static void sha256_finish(sha256_state *state, unsigned char digest[32]) { + uint64_t bits = state->processed_bits + (uint64_t)state->used * 8U; + state->block[state->used++] = 0x80U; + if (state->used > 56U) { + memset(state->block + state->used, 0, 64U - state->used); + sha256_transform(state, state->block); + state->used = 0; + } + memset(state->block + state->used, 0, 56U - state->used); + for (size_t index = 0; index < 8; ++index) + state->block[56U + index] = + (unsigned char)(bits >> (56U - 8U * index)); + sha256_transform(state, state->block); + for (size_t index = 0; index < 32; ++index) + digest[index] = (unsigned char) + (state->word[index / 4U] >> (24U - 8U * (index % 4U))); +} + +static int valid_ticket(const ib_reader_source_ticket *ticket) { + if (!ticket || ticket->navigation_generation == 0 || + ticket->byte_count == 0 || + ticket->byte_count > IB_READER_SOURCE_MAX_BYTES) + return 0; + size_t length = strnlen(ticket->request_id, + sizeof(ticket->request_id)); + if (length == 0 || length > IB_READER_REQUEST_ID_CAP) + return 0; + for (size_t index = 0; index < length; ++index) { + unsigned char ch = (unsigned char)ticket->request_id[index]; + if (!((ch >= 'a' && ch <= 'z') || + (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || + ch == '.' || ch == '_' || ch == '-')) + return 0; + } + if (strnlen(ticket->sha256_hex, sizeof(ticket->sha256_hex)) != 64U) + return 0; + for (size_t index = 0; index < 64; ++index) { + char ch = ticket->sha256_hex[index]; + if (!((ch >= '0' && ch <= '9') || + (ch >= 'a' && ch <= 'f'))) + return 0; + } + return 1; +} + +static int matching_digest(const unsigned char digest[32], + const char expected[65]) { + static const char alphabet[] = "0123456789abcdef"; + unsigned int differences = 0; + for (size_t index = 0; index < 32; ++index) { + differences |= (unsigned int) + (alphabet[digest[index] >> 4U] != expected[index * 2U]); + differences |= (unsigned int) + (alphabet[digest[index] & 15U] != expected[index * 2U + 1U]); + } + return differences == 0; +} + +static int64_t monotonic_milliseconds(void) { + struct timespec now; + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) return -1; + return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +static ssize_t read_bounded(int fd, void *buffer, size_t capacity, + int64_t deadline) { + for (;;) { + int64_t now = monotonic_milliseconds(); + if (now < 0 || now >= deadline) return -1; + int64_t remaining = deadline - now; + struct pollfd descriptor = { fd, POLLIN | POLLHUP, 0 }; + int status = poll(&descriptor, 1, remaining > INT_MAX ? + INT_MAX : (int)remaining); + if (status < 0 && errno == EINTR) continue; + if (status <= 0 || (descriptor.revents & (POLLERR | POLLNVAL))) + return -1; + ssize_t count = read(fd, buffer, capacity); + if (count < 0 && (errno == EINTR || errno == EAGAIN)) continue; + return count; + } +} + +void ib_reader_source_init(ib_reader_source *source) { + if (!source) return; + atomic_init(&source->wanted_navigation_generation, 0); + source->bytes = NULL; + source->byte_count = 0; + source->published_navigation_generation = 0; + source->request_id[0] = '\0'; +} + +void ib_reader_source_clear(ib_reader_source *source) { + if (!source) return; + free(source->bytes); + source->bytes = NULL; + source->byte_count = 0; + source->published_navigation_generation = 0; + source->request_id[0] = '\0'; + atomic_store(&source->wanted_navigation_generation, 0); +} + +void ib_reader_source_want(ib_reader_source *source, uint64_t generation) { + if (source) atomic_store(&source->wanted_navigation_generation, generation); +} + +int ib_reader_source_accept_fd(ib_reader_source *source, + const ib_reader_source_ticket *ticket, int fd) { + if (!source || !valid_ticket(ticket) || fd < 0) + return IB_READER_PFD_BAD_TICKET; + if (ticket->navigation_generation != + atomic_load(&source->wanted_navigation_generation)) + return IB_READER_PFD_STALE; + + unsigned char *candidate = malloc((size_t)ticket->byte_count + 1U); + if (!candidate) return IB_READER_PFD_IO; + sha256_state hash; + sha256_init(&hash); + size_t received = 0; + int result = IB_READER_PFD_IO; + int64_t start = monotonic_milliseconds(); + if (start < 0 || start > INT64_MAX - IB_PFD_READ_DEADLINE_MS) + goto failed; + int64_t deadline = start + IB_PFD_READ_DEADLINE_MS; + while (received < ticket->byte_count) { + size_t wanted = ticket->byte_count - received; + if (wanted > 65536U) wanted = 65536U; + ssize_t count = read_bounded(fd, candidate + received, wanted, deadline); + if (count <= 0) goto failed; + sha256_update(&hash, candidate + received, (size_t)count); + received += (size_t)count; + } + /* A source capability must end here: never silently accept a prefix + * from a larger or still-live stream. */ + unsigned char extra; + if (read_bounded(fd, &extra, 1, deadline) != 0) goto failed; + unsigned char digest[32]; + sha256_finish(&hash, digest); + if (!matching_digest(digest, ticket->sha256_hex)) { + result = IB_READER_PFD_DIGEST; + goto failed; + } + if (ticket->navigation_generation != + atomic_load(&source->wanted_navigation_generation)) { + result = IB_READER_PFD_STALE; + goto failed; + } + candidate[received] = '\0'; /* sentinel; body remains binary */ + unsigned char *previous = source->bytes; + source->bytes = candidate; + source->byte_count = received; + source->published_navigation_generation = ticket->navigation_generation; + memcpy(source->request_id, ticket->request_id, + strlen(ticket->request_id) + 1U); + free(previous); + return IB_READER_PFD_ACCEPTED; + +failed: + free(candidate); + return result; +} + +#if defined(__ANDROID__) && __ANDROID_API__ >= 29 +#include + +/* The private provider owns authorization and obtains the descriptor from a + * committed, receipt-verified source. The Binder transaction contains only a + * request identity and epoch; the reply contains a PFD, never a path. */ +int ib_reader_source_fetch_binder(ib_reader_source *source, + const ib_reader_source_ticket *ticket, + AIBinder *provider) { + if (!source || !valid_ticket(ticket) || !provider) + return IB_READER_PFD_BAD_TICKET; + if (ticket->navigation_generation != + atomic_load(&source->wanted_navigation_generation)) + return IB_READER_PFD_STALE; + + AParcel *request = NULL, *reply = NULL; + binder_status_t status = AIBinder_prepareTransaction(provider, &request); + if (status != STATUS_OK || !request) + return IB_READER_PFD_BINDER; + status = AParcel_writeString(request, ticket->request_id, + (int32_t)strlen(ticket->request_id)); + if (status == STATUS_OK) + status = AParcel_writeInt64(request, + (int64_t)ticket->navigation_generation); + if (status != STATUS_OK) { + AParcel_delete(request); + return IB_READER_PFD_BINDER; + } + /* request ownership transfers to AIBinder_transact. */ + status = AIBinder_transact(provider, 1U, &request, &reply, 0); + if (status != STATUS_OK || !reply) { + if (reply) AParcel_delete(reply); + return IB_READER_PFD_BINDER; + } + int32_t provider_status = -1; + int descriptor = -1; + status = AParcel_readInt32(reply, &provider_status); + if (status == STATUS_OK && provider_status == 0) + status = AParcel_readParcelFileDescriptor(reply, &descriptor); + AParcel_delete(reply); + if (status != STATUS_OK || provider_status != 0 || descriptor < 0) { + if (descriptor >= 0) close(descriptor); + return IB_READER_PFD_BINDER; + } + int result = ib_reader_source_accept_fd(source, ticket, descriptor); + close(descriptor); + return result; +} +#endif From ffc84e5b6fda494a15dd8368510ea3624ec22ab3 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:12:11 -0400 Subject: [PATCH 03/49] Exercise cross-process reader PFD verification and fail-closed source retention --- tests/test_android_reader_pfd.c | 201 ++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 tests/test_android_reader_pfd.c diff --git a/tests/test_android_reader_pfd.c b/tests/test_android_reader_pfd.c new file mode 100644 index 00000000..ffb7416b --- /dev/null +++ b/tests/test_android_reader_pfd.c @@ -0,0 +1,201 @@ +#define _POSIX_C_SOURCE 200809L +#include "../native/android/ib_reader_pfd.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const char *abc_digest = + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; +static const char *hello_digest = + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; +static const char *binary_digest = + "c8ac851bcdb2785ab14adb0b61e4743865e6999e03481b9409b67de2cdc29c4a"; + +static void must_write(int fd, const unsigned char *bytes, size_t count) { + while (count != 0) { + ssize_t written = write(fd, bytes, count); + if (written < 0 && errno == EINTR) continue; + assert(written > 0); + bytes += (size_t)written; + count -= (size_t)written; + } +} + +/* Host simulation only: SCM_RIGHTS carries an anonymous pipe between two + * independent processes. Android uses Binder's ParcelFileDescriptor instead. */ +static void send_descriptor(int socket, int descriptor) { + char byte = 'F'; + struct iovec segment = { &byte, sizeof(byte) }; + union { + struct cmsghdr align; + unsigned char space[CMSG_SPACE(sizeof(int))]; + } control; + memset(&control, 0, sizeof(control)); + struct msghdr message = {0}; + message.msg_iov = &segment; + message.msg_iovlen = 1; + message.msg_control = control.space; + message.msg_controllen = sizeof(control.space); + struct cmsghdr *header = CMSG_FIRSTHDR(&message); + assert(header != NULL); + header->cmsg_level = SOL_SOCKET; + header->cmsg_type = SCM_RIGHTS; + header->cmsg_len = CMSG_LEN(sizeof(int)); + memcpy(CMSG_DATA(header), &descriptor, sizeof(descriptor)); + assert(sendmsg(socket, &message, 0) == 1); +} + +static int receive_descriptor(int socket) { + char byte = '\0'; + struct iovec segment = { &byte, sizeof(byte) }; + union { + struct cmsghdr align; + unsigned char space[CMSG_SPACE(sizeof(int))]; + } control; + memset(&control, 0, sizeof(control)); + struct msghdr message = {0}; + message.msg_iov = &segment; + message.msg_iovlen = 1; + message.msg_control = control.space; + message.msg_controllen = sizeof(control.space); + assert(recvmsg(socket, &message, 0) == 1); + assert(byte == 'F' && (message.msg_flags & MSG_CTRUNC) == 0); + struct cmsghdr *header = CMSG_FIRSTHDR(&message); + assert(header != NULL && header->cmsg_level == SOL_SOCKET); + assert(header->cmsg_type == SCM_RIGHTS); + assert(header->cmsg_len == CMSG_LEN(sizeof(int))); + int descriptor = -1; + memcpy(&descriptor, CMSG_DATA(header), sizeof(descriptor)); + assert(descriptor >= 0); + return descriptor; +} + +static int transferred_stream(const unsigned char *data, size_t size) { + int sockets[2]; + assert(socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) == 0); + pid_t child = fork(); + assert(child >= 0); + if (child == 0) { + close(sockets[0]); + int stream[2]; + if (pipe(stream) != 0) _exit(33); + send_descriptor(sockets[1], stream[0]); + close(stream[0]); + if (size != 0) must_write(stream[1], data, size); + close(stream[1]); + close(sockets[1]); + _exit(0); + } + close(sockets[1]); + int descriptor = receive_descriptor(sockets[0]); + close(sockets[0]); + int status = 0; + assert(waitpid(child, &status, 0) == child); + assert(WIFEXITED(status) && WEXITSTATUS(status) == 0); + return descriptor; +} + +static ib_reader_source_ticket ticket(const char *name, uint64_t generation, + uint32_t count, const char *digest) { + ib_reader_source_ticket value = {0}; + assert(strlen(name) <= IB_READER_REQUEST_ID_CAP); + memcpy(value.request_id, name, strlen(name) + 1U); + value.navigation_generation = generation; + value.byte_count = count; + assert(strlen(digest) == 64U); + memcpy(value.sha256_hex, digest, 65U); + return value; +} + +static void intact(const ib_reader_source *source, const unsigned char *bytes, + size_t count, const char *request, uint64_t generation) { + assert(source->bytes != NULL); + assert(source->byte_count == count); + assert(memcmp(source->bytes, bytes, count) == 0); + assert(strcmp(source->request_id, request) == 0); + assert(source->published_navigation_generation == generation); +} + +int main(void) { + static const unsigned char abc[] = "abc"; + static const unsigned char hello[] = "hello"; + static const unsigned char binary[] = {'b','i','n',0,'a','r','y',255,'\n'}; + ib_reader_source source; + ib_reader_source_init(&source); + ib_reader_source_want(&source, 7U); + + ib_reader_source_ticket first = ticket("first", 7, 3, abc_digest); + int fd = transferred_stream(abc, 3); + assert(ib_reader_source_accept_fd(&source, &first, fd) == IB_READER_PFD_ACCEPTED); + close(fd); + intact(&source, abc, 3, "first", 7); + assert(source.bytes[3] == '\0'); + + /* Corrupt stream and metadata never evict the last published source. */ + ib_reader_source_ticket invalid_digest = ticket("broken", 7, 5, hello_digest); + invalid_digest.sha256_hex[0] = '0'; + fd = transferred_stream(hello, 5); + assert(ib_reader_source_accept_fd(&source, &invalid_digest, fd) == IB_READER_PFD_DIGEST); + close(fd); + intact(&source, abc, 3, "first", 7); + + fd = transferred_stream(abc, 2); + assert(ib_reader_source_accept_fd(&source, &first, fd) == IB_READER_PFD_IO); + close(fd); + intact(&source, abc, 3, "first", 7); + + static const unsigned char longer[] = "abcX"; + fd = transferred_stream(longer, 4); + assert(ib_reader_source_accept_fd(&source, &first, fd) == IB_READER_PFD_IO); + close(fd); + intact(&source, abc, 3, "first", 7); + + ib_reader_source_ticket invalid_name = ticket("..bad", 7, 3, abc_digest); + invalid_name.request_id[0] = '/'; + fd = transferred_stream(abc, 3); + assert(ib_reader_source_accept_fd(&source, &invalid_name, fd) == IB_READER_PFD_BAD_TICKET); + close(fd); + intact(&source, abc, 3, "first", 7); + + ib_reader_source_ticket too_large = ticket("large", 7, IB_READER_SOURCE_MAX_BYTES+1, + abc_digest); + fd = transferred_stream(abc, 3); + assert(ib_reader_source_accept_fd(&source, &too_large, fd) == IB_READER_PFD_BAD_TICKET); + close(fd); + intact(&source, abc, 3, "first", 7); + + /* New navigation invalidates an old PFD even if its hash is valid. */ + ib_reader_source_want(&source, 8U); + fd = transferred_stream(abc, 3); + assert(ib_reader_source_accept_fd(&source, &first, fd) == IB_READER_PFD_STALE); + close(fd); + intact(&source, abc, 3, "first", 7); + + ib_reader_source_ticket second = ticket("second", 8, 5, hello_digest); + fd = transferred_stream(hello, 5); + assert(ib_reader_source_accept_fd(&source, &second, fd) == IB_READER_PFD_ACCEPTED); + close(fd); + intact(&source, hello, 5, "second", 8); + + ib_reader_source_want(&source, 9U); + ib_reader_source_ticket third = ticket("binary-source", 9, sizeof(binary), binary_digest); + fd = transferred_stream(binary, sizeof(binary)); + assert(ib_reader_source_accept_fd(&source, &third, fd) == IB_READER_PFD_ACCEPTED); + close(fd); + intact(&source, binary, sizeof(binary), "binary-source", 9); + + ib_reader_source_clear(&source); + assert(source.bytes == NULL && source.byte_count == 0U); + puts("PASS: two-process descriptor transfer, SHA-256, bounded exact binary, stale/partial/extra/corrupt/oversized refusal, last-good preservation"); + puts("NOT_RUN: Android Binder service, Shizuku, native Idric screen wiring, actual C67"); + return 0; +} From 0dac735591593208b078c5490b7df609ab15d158 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:12:27 -0400 Subject: [PATCH 04/49] Check native reader PFD admission on host and cross-compile Binder ABI --- .github/workflows/ib-c67-reader-pfd.yml | 58 +++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/ib-c67-reader-pfd.yml diff --git a/.github/workflows/ib-c67-reader-pfd.yml b/.github/workflows/ib-c67-reader-pfd.yml new file mode 100644 index 00000000..b2820fd1 --- /dev/null +++ b/.github/workflows/ib-c67-reader-pfd.yml @@ -0,0 +1,58 @@ +name: IB checked reader Binder PFD admission + +on: + pull_request: + paths: + - 'native/android/ib_reader_pfd.c' + - 'native/android/ib_reader_pfd.h' + - 'tests/test_android_reader_pfd.c' + - '.github/workflows/ib-c67-reader-pfd.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + pfd-source-admission: + runs-on: ubuntu-24.04 + steps: + - name: Checkout pinned PR source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Host C11 two-process descriptor tests + run: | + set -eu + mkdir -p build/c67-pfd/host + cc -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \ + native/android/ib_reader_pfd.c tests/test_android_reader_pfd.c \ + -o build/c67-pfd/host/test-reader-pfd + ./build/c67-pfd/host/test-reader-pfd | tee build/c67-pfd/host/receipt.txt + grep -F 'NOT_RUN: Android Binder service' build/c67-pfd/host/receipt.txt + sha256sum build/c67-pfd/host/test-reader-pfd \ + > build/c67-pfd/host/sha256.txt + - name: Cross-build real Android libbinder_ndk PFD reader on both ABIs + run: | + set -eu + ndk_version=27.3.13750724 + yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;$ndk_version" >/dev/null + toolchain="$ANDROID_HOME/ndk/$ndk_version/toolchains/llvm/prebuilt/linux-x86_64/bin" + for entry in 'armeabi-v7a armv7a-linux-androideabi29 ARM' 'arm64-v8a aarch64-linux-android29 AArch64'; do + set -- $entry + abi=$1 target=$2 machine=$3 + mkdir -p "build/c67-pfd/$abi" + "$toolchain/$target-clang" -std=c11 -O2 -Wall -Wextra \ + -Wpedantic -Werror -fPIC -shared native/android/ib_reader_pfd.c \ + -lbinder_ndk -o "build/c67-pfd/$abi/libib_reader_pfd.so" + "$toolchain/llvm-readelf" -h "build/c67-pfd/$abi/libib_reader_pfd.so" \ + > "build/c67-pfd/$abi/elf.txt" + grep -F "Machine:" "build/c67-pfd/$abi/elf.txt" | grep -F "$machine" + sha256sum "build/c67-pfd/$abi/libib_reader_pfd.so" \ + > "build/c67-pfd/$abi/sha256.txt" + printf 'abi\t%s\nandroid_link\tlibbinder_ndk\nphysical_device\tNOT_RUN\nidric_screen\tNOT_CONNECTED\n' \ + "$abi" > "build/c67-pfd/$abi/receipt.tsv" + done + - name: Publish ABI artifacts, not an APK + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: ib-c67-reader-pfd-native + path: build/c67-pfd/ + if-no-files-found: error From e077732e5397338937f9019f43d0d4549e4915c8 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:13:03 -0400 Subject: [PATCH 05/49] =?UTF-8?q?Document=20private=20scoped=20Binder/PFD?= =?UTF-8?q?=20contract=20and=20remaining=20Idri=C3=A7=20Android=20integrat?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/c67-reader-pfd.md | 97 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/c67-reader-pfd.md diff --git a/docs/c67-reader-pfd.md b/docs/c67-reader-pfd.md new file mode 100644 index 00000000..1c67d037 --- /dev/null +++ b/docs/c67-reader-pfd.md @@ -0,0 +1,97 @@ +# IB C67: private Binder/PFD reader handoff (bounded receiver) + +**Status:** reader-side native receiver and independent host simulation only. +This branch is stacked on IB PR #152, which stores checked ICU body bytes in +the existing userspace appendfat Arena. **No URL-capable APK, provider service, +Idriç screen connection, or physical C67 Binder execution is claimed.** + +## Required connection + + Idriç browser-owned navigation epoch + source identity + → Shizuku-authorized ICU worker (no Android-reader HTTP) + → exact captured bytes → checked appendfat Arena source + → source-owner verifies immutable receipt + reopened bytes + → private app-scoped Binder provider: OPEN_COMMITTED_SOURCE(request,epoch) + → one read-only, per-open ParcelFileDescriptor (not Arena file FD) + → native reader verifies exact stream length + SHA-256 + epoch + → source-linked extraction / Idriç Prepaint → existing native views + +The supplier must first consume the **real** committed source using +bin/ib_android_arena_read.grease (which does not emit bytes until its +digest check passes). Materialize the verified bounded source into a +fresh pipe/memfd whose descriptor, rather than a path, is transferred. +Never hand out the appendfat Arena's descriptor or rely on a shared +/data/local/tmp response. Never give the reader a network client. + +Private Binder implementation contract, transaction code **1**: + +- Request parcel: validated UTF-8 request ID and signed 64-bit navigation + generation. A real provider authorizes Binder calling UID against an + explicit same-signer/allowed-reader identity before opening a source. + Missing/denied authorization returns a nonzero status and no descriptor. +- Reply parcel: int32 status **0** then a ParcelFileDescriptor with **only** + the already checked committed source's bytes. Provider closes its write + side after the declared byte count. A successful receipt alone does not + admit browser navigation. +- Browser-held admission ticket: request ID, generation, byte count + (1–4194304) and lowercase SHA-256 copied from the previously checked + immutable source receipt, **never from untrusted page content**. The + ticket must be authenticated at the broker/reader boundary. For a + matching ticket, the receiver verifies the entire stream, checks for EOF, + and compares SHA-256 before replacing the last displayed source. +- A newer browser navigation updates the expected generation. Late results, + corrupt/replayed metadata, malformed IDs, extra/short input, over-limit + bodies, reader/provider death and stalled streams are refused. The + existing last-good snapshot remains owned by the reader. + +native/android/ib_reader_pfd.{h,c} implement the **receiving** C11 +boundary. On Android API 29+ they use NDK Binder parcel transactions and +AParcel_readParcelFileDescriptor. The consumer does not discover a global +service, request Shizuku permission, decide URLs, parse HTML, or publish +history. It requires an already authorized provider Binder handle supplied +by the host adapter. This is compatible with Android 14/SDK 34 on A1/C67; +older Android API 28 is not yet supported by this native Binder adapter. + +This is **not** a second canonical store. The C receiver holds one +replaceable in-memory candidate. The browser-owned committed source and +navigation journal continue to reside in existing IB/Cauldron/Arena. +Raw bytes remain binary until source verification and the existing Idriç +extraction/Prepaint decision. It is not correct to render raw HTML as if it +were already the semantic prepaint projection. + +## Source and validation + +test_android_reader_pfd.c uses two real Linux processes and transfers an +anonymous pipe descriptor via SCM_RIGHTS. This checks the exact native +consumer's binary fidelity, known SHA-256 vectors, good-source retention, +and fail-closed refusal on corruption, short/extra data, bad IDs, +over-limit tickets and stale navigation generations. **SCM_RIGHTS is a +host stand-in for descriptor ownership, not a claim of tested Android +Binder delivery or tested cross-UID Android authority.** + +The independent Android CI lane cross-compiles the actual NDK Binder +calls for ARM32 and ARM64 and retains digests. Cross-compilation does +**not** prove the provider exists, the Idriç DEX can call this library, +or that an Android Binder transaction has happened. + +## Required next integration + +1. Implement the existing Binder host provider's private authenticated + descriptor operation, creating a fresh bounded FD only after the + canonical Arena receipt checks. Handle shell UID 2000 and app-private + storage correctly; do not assume they share a directory or UID. +2. Bind the native C receiver to the **existing checked Idriç Material + reader** (#129/#147), with qualified DEX/native FFI and lifecycle. + Do not introduce Java/Kotlin application logic or a replacement UI. +3. Admit a page only when ICU's resolved URL, status, encoding and + redirect evidence are available; retain the source and link events + through Idriç. At present PR #152 says navigation_admitted=NO. +4. Device-check denied/missing Shizuku, private reader UID, real ICU + HTTPS, committed Arena and readback digest, actual Binder PFD + transaction, page presentation, copying text, replacement/restart + recovery and late/out-of-order completions on the physical C67. + Mark each absent test NOT_RUN. + +Existing source owners: IB #151, #142, #141, #97; IB PR #112 +(Binder-first host), #129/#147 (checked Android reader), #152 +(ICU/Arena). ICU PR #35 owns real resolved-URL/redirect metadata. From 9ed23d8ba19650d65a37ccaa5ea465ab2c702df6 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:14:20 -0400 Subject: [PATCH 06/49] Define UID-scoped native Arena PFD Binder provider contract --- native/android/ib_arena_pfd_provider.h | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 native/android/ib_arena_pfd_provider.h diff --git a/native/android/ib_arena_pfd_provider.h b/native/android/ib_arena_pfd_provider.h new file mode 100644 index 00000000..f6f004ec --- /dev/null +++ b/native/android/ib_arena_pfd_provider.h @@ -0,0 +1,30 @@ +#ifndef IB_ANDROID_ARENA_PFD_PROVIDER_H +#define IB_ANDROID_ARENA_PFD_PROVIDER_H + +#include +#include + +#if defined(__ANDROID__) && __ANDROID_API__ >= 29 +#include + +/* Source owner, not the shell process, supplies an already checked fresh FD + * containing exactly one committed source. open_verified_fd may NOT return a + * raw descriptor of the entire canonical Arena, a path or a network socket. */ +typedef int (*ib_open_committed_source)(void *source_owner, + const char *request_id, + uint64_t navigation_generation, + int *owned_read_fd); + +typedef struct { + uid_t allowed_reader_uid; /* checked application UID, not shell */ + ib_open_committed_source open_verified_fd; + void *source_owner; +} ib_arena_pfd_provider; + +/* Construct a private app-owned Binder object. The app's trusted host must + * deliver this capability to its allowed reader; it is NOT registered with + * the global Android service manager. Provider configuration must outlive + * the returned Binder object. Caller releases returned Binder reference. */ +AIBinder *ib_arena_pfd_provider_new(ib_arena_pfd_provider *provider); +#endif +#endif From 851decaf844027d0454143aa47b860e2670f5d20 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:14:42 -0400 Subject: [PATCH 07/49] Implement UID-gated Binder PFD provider for verified Arena sources --- native/android/ib_arena_pfd_provider.c | 119 +++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 native/android/ib_arena_pfd_provider.c diff --git a/native/android/ib_arena_pfd_provider.c b/native/android/ib_arena_pfd_provider.c new file mode 100644 index 00000000..d2111650 --- /dev/null +++ b/native/android/ib_arena_pfd_provider.c @@ -0,0 +1,119 @@ +#define _POSIX_C_SOURCE 200809L +#include "ib_arena_pfd_provider.h" + +#if defined(__ANDROID__) && __ANDROID_API__ >= 29 + +#include +#include +#include +#include +#include +#include +#include + +#define REQUEST_MAX_BYTES 64 +#define OPEN_COMMITTED_SOURCE_TRANSACTION 1U + +typedef struct { + char request_id[REQUEST_MAX_BYTES + 1U]; + int32_t provided_capacity; +} parsed_request; + +static bool allocate_request(void *userdata, int32_t length, char **buffer) { + parsed_request *request = (parsed_request *)userdata; + if (!request || !buffer || length <= 0 || + length > (int32_t)sizeof(request->request_id)) + return false; + request->provided_capacity = length; + *buffer = request->request_id; + return true; +} + +static bool valid_id(const parsed_request *request) { + if (!request || request->provided_capacity <= 1) return false; + size_t length = strnlen(request->request_id, + sizeof(request->request_id)); + if (length == 0 || length >= sizeof(request->request_id) || + length + 1U != (size_t)request->provided_capacity) + return false; + for (size_t index = 0; index < length; ++index) { + unsigned char c = (unsigned char)request->request_id[index]; + if (!((c >= '0' && c <= '9') || + (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + c == '.' || c == '_' || c == '-')) + return false; + } + return true; +} + +static void *created(void *userdata) { + return userdata; +} + +static void destroyed(void *userdata) { + /* The trusted application host owns this configuration throughout the + * lifetime of the bound private Binder service. */ + (void)userdata; +} + +static binder_status_t respond(AParcel *out, int32_t error) { + return AParcel_writeInt32(out, error); +} + +static binder_status_t transact(AIBinder *binder, transaction_code_t code, + const AParcel *in, AParcel *out) { + if (code != OPEN_COMMITTED_SOURCE_TRANSACTION) + return STATUS_UNKNOWN_TRANSACTION; + ib_arena_pfd_provider *provider = + (ib_arena_pfd_provider *)AIBinder_getUserData(binder); + if (!provider || !provider->open_verified_fd) + return respond(out, 4); + + /* Read access is explicitly app-scoped; UID 2000 running ICU is not a + * reader and has no implicit authority over private retained sources. */ + if (AIBinder_getCallingUid() != provider->allowed_reader_uid) + return respond(out, 1); + + parsed_request request = {{0}, 0}; + int64_t generation = 0; + binder_status_t status = AParcel_readString(in, &request, + allocate_request); + if (status != STATUS_OK) + return respond(out, 2); + status = AParcel_readInt64(in, &generation); + if (status != STATUS_OK || generation <= 0 || !valid_id(&request)) + return respond(out, 2); + + int descriptor = -1; + int result = provider->open_verified_fd(provider->source_owner, + request.request_id, + (uint64_t)generation, + &descriptor); + if (result != 0 || descriptor < 0) { + if (descriptor >= 0) close(descriptor); + return respond(out, 3); + } + status = respond(out, 0); + if (status == STATUS_OK) + status = AParcel_writeParcelFileDescriptor(out, descriptor); + close(descriptor); + return status; +} + +AIBinder *ib_arena_pfd_provider_new(ib_arena_pfd_provider *provider) { + if (!provider || !provider->open_verified_fd || + provider->allowed_reader_uid == (uid_t)-1) + return NULL; + /* Called at single-threaded host initialization, not once per request. */ + static AIBinder_Class *binder_class = NULL; + if (!binder_class) { + binder_class = AIBinder_Class_define( + "org.isomorphisms.ib.ArenaCommittedSource.v1", + created, destroyed, transact + ); + } + if (!binder_class) return NULL; + return AIBinder_new(binder_class, provider); +} +#endif From 554ef2dadc5b56c9616d6601095e9b3f5a4ed3e6 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:14:53 -0400 Subject: [PATCH 08/49] Cross-build private Arena Binder provider with native reader --- .github/workflows/ib-c67-reader-pfd.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ib-c67-reader-pfd.yml b/.github/workflows/ib-c67-reader-pfd.yml index b2820fd1..e6f97809 100644 --- a/.github/workflows/ib-c67-reader-pfd.yml +++ b/.github/workflows/ib-c67-reader-pfd.yml @@ -5,6 +5,8 @@ on: paths: - 'native/android/ib_reader_pfd.c' - 'native/android/ib_reader_pfd.h' + - 'native/android/ib_arena_pfd_provider.c' + - 'native/android/ib_arena_pfd_provider.h' - 'tests/test_android_reader_pfd.c' - '.github/workflows/ib-c67-reader-pfd.yml' workflow_dispatch: @@ -41,6 +43,7 @@ jobs: mkdir -p "build/c67-pfd/$abi" "$toolchain/$target-clang" -std=c11 -O2 -Wall -Wextra \ -Wpedantic -Werror -fPIC -shared native/android/ib_reader_pfd.c \ + native/android/ib_arena_pfd_provider.c \ -lbinder_ndk -o "build/c67-pfd/$abi/libib_reader_pfd.so" "$toolchain/llvm-readelf" -h "build/c67-pfd/$abi/libib_reader_pfd.so" \ > "build/c67-pfd/$abi/elf.txt" From 7e808618dc275f30eb9f4cd4e1854b35e05c1a1d Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:15:50 -0400 Subject: [PATCH 09/49] Refuse Arena file descriptors and shell UID at private Binder provider --- native/android/ib_arena_pfd_provider.c | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/native/android/ib_arena_pfd_provider.c b/native/android/ib_arena_pfd_provider.c index d2111650..51f44fa2 100644 --- a/native/android/ib_arena_pfd_provider.c +++ b/native/android/ib_arena_pfd_provider.c @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include #include #include @@ -94,6 +96,15 @@ static binder_status_t transact(AIBinder *binder, transaction_code_t code, if (descriptor >= 0) close(descriptor); return respond(out, 3); } + /* Never parcel a file-backed Arena descriptor or a writable capability. + * A fresh read-only anonymous pipe is the only admitted publication. */ + struct stat info; + int flags = fcntl(descriptor, F_GETFL); + if (flags < 0 || fstat(descriptor, &info) != 0 || + !S_ISFIFO(info.st_mode) || (flags & O_ACCMODE) != O_RDONLY) { + close(descriptor); + return respond(out, 3); + } status = respond(out, 0); if (status == STATUS_OK) status = AParcel_writeParcelFileDescriptor(out, descriptor); @@ -103,7 +114,7 @@ static binder_status_t transact(AIBinder *binder, transaction_code_t code, AIBinder *ib_arena_pfd_provider_new(ib_arena_pfd_provider *provider) { if (!provider || !provider->open_verified_fd || - provider->allowed_reader_uid == (uid_t)-1) + provider->allowed_reader_uid < (uid_t)10000) return NULL; /* Called at single-threaded host initialization, not once per request. */ static AIBinder_Class *binder_class = NULL; From 9d156b20f394f9b40ecc2d2522175e75621a3137 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:16:01 -0400 Subject: [PATCH 10/49] Admit only finite read-only PFD pipes, not backing Arena descriptors --- native/android/ib_reader_pfd.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/native/android/ib_reader_pfd.c b/native/android/ib_reader_pfd.c index 50e020bb..ca22610c 100644 --- a/native/android/ib_reader_pfd.c +++ b/native/android/ib_reader_pfd.c @@ -2,6 +2,8 @@ #include "ib_reader_pfd.h" #include +#include +#include #include #include #include @@ -217,6 +219,12 @@ int ib_reader_source_accept_fd(ib_reader_source *source, if (ticket->navigation_generation != atomic_load(&source->wanted_navigation_generation)) return IB_READER_PFD_STALE; + /* A bounded source pipe, never the backing Arena file or a writable FD. */ + struct stat info; + int flags = fcntl(fd, F_GETFL); + if (flags < 0 || fstat(fd, &info) != 0 || + !S_ISFIFO(info.st_mode) || (flags & O_ACCMODE) != O_RDONLY) + return IB_READER_PFD_BAD_TICKET; unsigned char *candidate = malloc((size_t)ticket->byte_count + 1U); if (!candidate) return IB_READER_PFD_IO; From 26b81c81251ea11c7fed1560c8301a496c512081 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:16:14 -0400 Subject: [PATCH 11/49] Refuse path-backed and writable descriptors in reader acceptance --- tests/test_android_reader_pfd.c | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/test_android_reader_pfd.c b/tests/test_android_reader_pfd.c index ffb7416b..f57ad300 100644 --- a/tests/test_android_reader_pfd.c +++ b/tests/test_android_reader_pfd.c @@ -140,6 +140,23 @@ int main(void) { intact(&source, abc, 3, "first", 7); assert(source.bytes[3] == '\0'); + /* A readable file, even one with valid data/digest, is not an + * accepted PFD: the reader must never receive the Arena file. */ + FILE *file = tmpfile(); + assert(file != NULL); + assert(fwrite(abc, 1, 3, file) == 3U); + assert(fflush(file) == 0 && fseek(file, 0, SEEK_SET) == 0); + assert(ib_reader_source_accept_fd(&source, &first, fileno(file)) == + IB_READER_PFD_BAD_TICKET); + assert(fclose(file) == 0); + int rejected_pipe[2]; + assert(pipe(rejected_pipe) == 0); + assert(ib_reader_source_accept_fd(&source, &first, rejected_pipe[1]) == + IB_READER_PFD_BAD_TICKET); + close(rejected_pipe[0]); + close(rejected_pipe[1]); + intact(&source, abc, 3, "first", 7); + /* Corrupt stream and metadata never evict the last published source. */ ib_reader_source_ticket invalid_digest = ticket("broken", 7, 5, hello_digest); invalid_digest.sha256_hex[0] = '0'; @@ -195,7 +212,7 @@ int main(void) { ib_reader_source_clear(&source); assert(source.bytes == NULL && source.byte_count == 0U); - puts("PASS: two-process descriptor transfer, SHA-256, bounded exact binary, stale/partial/extra/corrupt/oversized refusal, last-good preservation"); + puts("PASS: two-process descriptor transfer, SHA-256, bounded exact binary, file/writable/stale/partial/extra/corrupt/oversized refusal, last-good preservation"); puts("NOT_RUN: Android Binder service, Shizuku, native Idric screen wiring, actual C67"); return 0; } From a8e7dd8bed5dde7fb7094f4149e5187bcba64fa6 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:16:34 -0400 Subject: [PATCH 12/49] Describe private native provider and accepted read-only pipe boundary --- docs/c67-reader-pfd.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/c67-reader-pfd.md b/docs/c67-reader-pfd.md index 1c67d037..7157f43b 100644 --- a/docs/c67-reader-pfd.md +++ b/docs/c67-reader-pfd.md @@ -1,6 +1,6 @@ # IB C67: private Binder/PFD reader handoff (bounded receiver) -**Status:** reader-side native receiver and independent host simulation only. +**Status:** native Binder provider/receiver contracts and independent host simulation only. This branch is stacked on IB PR #152, which stores checked ICU body bytes in the existing userspace appendfat Arena. **No URL-capable APK, provider service, Idriç screen connection, or physical C67 Binder execution is claimed.** @@ -16,10 +16,10 @@ Idriç screen connection, or physical C67 Binder execution is claimed.** → native reader verifies exact stream length + SHA-256 + epoch → source-linked extraction / Idriç Prepaint → existing native views -The supplier must first consume the **real** committed source using +The eventual app-scoped source owner must first consume the **real** committed source using bin/ib_android_arena_read.grease (which does not emit bytes until its digest check passes). Materialize the verified bounded source into a -fresh pipe/memfd whose descriptor, rather than a path, is transferred. +fresh **read-only anonymous pipe** whose descriptor, rather than a path, is transferred. A backing Arena file descriptor, writable descriptor or socket is refused. Never hand out the appendfat Arena's descriptor or rely on a shared /data/local/tmp response. Never give the reader a network client. @@ -44,6 +44,12 @@ Private Binder implementation contract, transaction code **1**: bodies, reader/provider death and stalled streams are refused. The existing last-good snapshot remains owned by the reader. +native/android/ib_arena_pfd_provider.{h,c} implement a narrowly UID-gated +**serving** Binder object (the host supplies its verified-source callback). +The object must be handed privately to the checked reader by the app host: +no global Binder service registration is used here. The callback that opens +the validated Arena receipt and populates its read-only pipe is still missing. + native/android/ib_reader_pfd.{h,c} implement the **receiving** C11 boundary. On Android API 29+ they use NDK Binder parcel transactions and AParcel_readParcelFileDescriptor. The consumer does not discover a global @@ -76,10 +82,11 @@ or that an Android Binder transaction has happened. ## Required next integration -1. Implement the existing Binder host provider's private authenticated - descriptor operation, creating a fresh bounded FD only after the - canonical Arena receipt checks. Handle shell UID 2000 and app-private - storage correctly; do not assume they share a directory or UID. +1. Connect the native provider object to the existing Binder host. Implement + its checked-source callback, producing a fresh anonymous read-only pipe + only after canonical Arena verification. Resolve reader app UID from + trusted package identity. Keep shell UID 2000 outside reader authorization; + it cannot assume access to the app-private Arena. 2. Bind the native C receiver to the **existing checked Idriç Material reader** (#129/#147), with qualified DEX/native FFI and lifecycle. Do not introduce Java/Kotlin application logic or a replacement UI. From a1a9963ed6aa1bb7deb3e8b9dcc7e1f0f0d33bfb Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:18:46 -0400 Subject: [PATCH 13/49] Expose shared private Binder class for remote proxy association --- native/android/ib_arena_pfd_provider.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/native/android/ib_arena_pfd_provider.h b/native/android/ib_arena_pfd_provider.h index f6f004ec..8fe520cf 100644 --- a/native/android/ib_arena_pfd_provider.h +++ b/native/android/ib_arena_pfd_provider.h @@ -26,5 +26,9 @@ typedef struct { * the global Android service manager. Provider configuration must outlive * the returned Binder object. Caller releases returned Binder reference. */ AIBinder *ib_arena_pfd_provider_new(ib_arena_pfd_provider *provider); + +/* Local service and remote proxy must associate the same Binder interface + * class before sending typed transactions. */ +const AIBinder_Class *ib_arena_pfd_provider_class(void); #endif #endif From b2c51944855aa182139067642effd56b46835d9b Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:18:55 -0400 Subject: [PATCH 14/49] Share native Binder service class identity with consumer proxy --- native/android/ib_arena_pfd_provider.c | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/native/android/ib_arena_pfd_provider.c b/native/android/ib_arena_pfd_provider.c index 51f44fa2..d993ddd4 100644 --- a/native/android/ib_arena_pfd_provider.c +++ b/native/android/ib_arena_pfd_provider.c @@ -112,11 +112,9 @@ static binder_status_t transact(AIBinder *binder, transaction_code_t code, return status; } -AIBinder *ib_arena_pfd_provider_new(ib_arena_pfd_provider *provider) { - if (!provider || !provider->open_verified_fd || - provider->allowed_reader_uid < (uid_t)10000) - return NULL; - /* Called at single-threaded host initialization, not once per request. */ +const AIBinder_Class *ib_arena_pfd_provider_class(void) { + /* Initialized by the host before concurrent Binder dispatch. A reader + * proxy associates this same interface descriptor before transacting. */ static AIBinder_Class *binder_class = NULL; if (!binder_class) { binder_class = AIBinder_Class_define( @@ -124,6 +122,14 @@ AIBinder *ib_arena_pfd_provider_new(ib_arena_pfd_provider *provider) { created, destroyed, transact ); } + return binder_class; +} + +AIBinder *ib_arena_pfd_provider_new(ib_arena_pfd_provider *provider) { + if (!provider || !provider->open_verified_fd || + provider->allowed_reader_uid < (uid_t)10000) + return NULL; + const AIBinder_Class *binder_class = ib_arena_pfd_provider_class(); if (!binder_class) return NULL; return AIBinder_new(binder_class, provider); } From 36dd6d1564736c993006c7981a041806495e08ac Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:19:02 -0400 Subject: [PATCH 15/49] Associate authenticated private Binder interface class before client transaction --- native/android/ib_reader_pfd.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/native/android/ib_reader_pfd.c b/native/android/ib_reader_pfd.c index ca22610c..109ca95f 100644 --- a/native/android/ib_reader_pfd.c +++ b/native/android/ib_reader_pfd.c @@ -276,6 +276,7 @@ int ib_reader_source_accept_fd(ib_reader_source *source, #if defined(__ANDROID__) && __ANDROID_API__ >= 29 #include +#include "ib_arena_pfd_provider.h" /* The private provider owns authorization and obtains the descriptor from a * committed, receipt-verified source. The Binder transaction contains only a @@ -289,6 +290,13 @@ int ib_reader_source_fetch_binder(ib_reader_source *source, atomic_load(&source->wanted_navigation_generation)) return IB_READER_PFD_STALE; + /* A binder proxy requires typed class association before transaction. + * Check the exact private interface descriptor; never send to an + * unassociated or unrelated Binder endpoint. */ + const AIBinder_Class *clazz = ib_arena_pfd_provider_class(); + if (!clazz || !AIBinder_associateClass(provider, clazz)) + return IB_READER_PFD_BINDER; + AParcel *request = NULL, *reply = NULL; binder_status_t status = AIBinder_prepareTransaction(provider, &request); if (status != STATUS_OK || !request) From 44a82b0bcca9ee22ff92292d7fc12bd363604ec9 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:23:33 -0400 Subject: [PATCH 16/49] Expose checked SHA-256 for Arena source owner validation --- native/android/ib_reader_pfd.h | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/native/android/ib_reader_pfd.h b/native/android/ib_reader_pfd.h index 4a832257..2fab3c46 100644 --- a/native/android/ib_reader_pfd.h +++ b/native/android/ib_reader_pfd.h @@ -42,6 +42,11 @@ void ib_reader_source_init(ib_reader_source *source); void ib_reader_source_clear(ib_reader_source *source); void ib_reader_source_want(ib_reader_source *source, uint64_t generation); +/* Same checked SHA-256 implementation for reader and source owner; never + * substitute textual CLI output for the captured binary response. */ +void ib_reader_sha256_hex(const unsigned char *bytes, size_t count, + char hex[65]); + /* Does NOT close fd. Requires a finite stream ending at exactly byte_count. * Checks deadline, length, SHA-256 and generation before exposing any byte. */ int ib_reader_source_accept_fd(ib_reader_source *source, From 1cfe4eb317ee2cf6ce691e7046e750e4690a494d Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:23:35 -0400 Subject: [PATCH 17/49] Share reader SHA-256 with private Arena source owner --- native/android/ib_reader_pfd.c | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/native/android/ib_reader_pfd.c b/native/android/ib_reader_pfd.c index 109ca95f..4e71522f 100644 --- a/native/android/ib_reader_pfd.c +++ b/native/android/ib_reader_pfd.c @@ -152,6 +152,21 @@ static int valid_ticket(const ib_reader_source_ticket *ticket) { return 1; } +void ib_reader_sha256_hex(const unsigned char *bytes, size_t count, + char hex[65]) { + static const char alphabet[] = "0123456789abcdef"; + sha256_state hash; + unsigned char digest[32]; + sha256_init(&hash); + if (count != 0) sha256_update(&hash, bytes, count); + sha256_finish(&hash, digest); + for (size_t index = 0; index < 32; ++index) { + hex[index * 2U] = alphabet[digest[index] >> 4U]; + hex[index * 2U + 1U] = alphabet[digest[index] & 15U]; + } + hex[64] = '\0'; +} + static int matching_digest(const unsigned char digest[32], const char expected[65]) { static const char alphabet[] = "0123456789abcdef"; From 9cf28c2f6c38cb12bb906a844e061ebfab34f943 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:24:08 -0400 Subject: [PATCH 18/49] Define app-private source owner callback for canonical Arena and PFD --- native/android/ib_arena_source_owner.h | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 native/android/ib_arena_source_owner.h diff --git a/native/android/ib_arena_source_owner.h b/native/android/ib_arena_source_owner.h new file mode 100644 index 00000000..ba311abb --- /dev/null +++ b/native/android/ib_arena_source_owner.h @@ -0,0 +1,39 @@ +#ifndef IB_ANDROID_VERIFIED_ARENA_SOURCE_H +#define IB_ANDROID_VERIFIED_ARENA_SOURCE_H + +#include "ib_reader_pfd.h" +#include +#include +#include + +#define IB_ARENA_PRIVATE_ROOT_CAP 4096U + +typedef struct { + char private_root[IB_ARENA_PRIVATE_ROOT_CAP]; + atomic_uint_fast64_t wanted_navigation_generation; +} ib_arena_source_owner; + +/* The trusted application host passes its existing app-private Arena root + * (e.g. Context.getFilesDir()/arena). This does not choose/create a store. */ +int ib_arena_source_owner_init(ib_arena_source_owner *owner, + const char *trusted_private_root); +void ib_arena_source_owner_want(ib_arena_source_owner *owner, + uint64_t navigation_generation); + +/* All-or-nothing source-open. The already committed canonical appendfat + * range and private receipt are re-read and SHA-256 checked before returning + * an independent, read-only anonymous pipe. The returned receipt-derived + * ticket is the sole expected metadata for the reader; navigation itself is + * NOT admitted by this byte-transfer function. Caller owns *read_fd. */ +int ib_arena_source_owner_open(ib_arena_source_owner *owner, + const char *request_id, + uint64_t navigation_generation, + ib_reader_source_ticket *ticket, + int *read_fd); + +/* Adapter for ib_arena_pfd_provider.open_verified_fd. */ +int ib_arena_source_owner_open_binder(void *owner, const char *request_id, + uint64_t navigation_generation, + int *read_fd); + +#endif From 571d77ee4ad996793dd2ab0c61e6da6b0ace64a7 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:24:50 -0400 Subject: [PATCH 19/49] Read canonical private Arena receipt and emit verified finite PFD pipe --- native/android/ib_arena_source_owner.c | 378 +++++++++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 native/android/ib_arena_source_owner.c diff --git a/native/android/ib_arena_source_owner.c b/native/android/ib_arena_source_owner.c new file mode 100644 index 00000000..5dd6abfc --- /dev/null +++ b/native/android/ib_arena_source_owner.c @@ -0,0 +1,378 @@ +#define _GNU_SOURCE +#define _FILE_OFFSET_BITS 64 +#include "ib_arena_source_owner.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Source-only receipts from PR #152. No parsing or storage authority here: + * the canonical appendfat .used pointer is the authoritative commit boundary. + * The receipt supplies the exact source offset, size and digest. */ +enum receipt_key { + RK_SCHEMA, RK_REQUEST, RK_REQUESTED_URL, RK_RESOLVED_URL, RK_STATUS, + RK_MIME, RK_TRANSPORT, RK_ARENA, RK_OFFSET, RK_END, RK_LENGTH, + RK_SHA256, RK_REOPEN, RK_NAVIGATION, RK_COUNT +}; +static const char *const receipt_names[RK_COUNT] = { + "schema", "request_id", "requested_url", "resolved_url", "http_status", + "mime_type", "transport", "arena_name", "arena_offset", "arena_end", + "byte_count", "sha256", "reopen_check", "navigation_admitted" +}; +#define RECEIPT_MAX_BYTES 8192U +#define SOURCE_WRITER_DEADLINE_MS 5000 +#define SOURCE_MAX_CONCURRENT_WRITERS 2U +static atomic_uint source_writers = 0; + +typedef struct { + unsigned char *bytes; + uint32_t length; + int writer_fd; +} source_writer; + +static int64_t time_milliseconds(void) { + struct timespec now; + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) return -1; + return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +static int safe_request_id(const char *id) { + if (!id) return 0; + size_t length = strnlen(id, IB_READER_REQUEST_ID_CAP + 1U); + if (length == 0 || length > IB_READER_REQUEST_ID_CAP) return 0; + for (size_t i = 0; i < length; ++i) { + unsigned char ch = (unsigned char)id[i]; + if (!((ch >= 'a' && ch <= 'z') || + (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || + ch == '.' || ch == '_' || ch == '-')) return 0; + } + return strcmp(id, ".") != 0 && strcmp(id, "..") != 0; +} + +static int parse_uint(const char *value, uint64_t *out) { + if (!value || !*value || !out) return -1; + for (const char *ch = value; *ch; ++ch) { + if (*ch < '0' || *ch > '9') return -1; + } + errno = 0; + char *end = NULL; + uintmax_t parsed = strtoumax(value, &end, 10); + if (errno != 0 || !end || *end != '\0' || parsed > INT64_MAX) + return -1; + *out = (uint64_t)parsed; + return 0; +} + +static int parse_receipt(char *text, size_t size, const char *request_id, + uint64_t *offset, uint32_t *length, + char sha256[65]) { + char *fields[RK_COUNT] = {0}; + size_t found = 0; + char *position = text, *end = text + size; + while (position < end) { + char *newline = memchr(position, '\n', (size_t)(end - position)); + if (!newline) return -1; + char *separator = memchr(position, '\t', + (size_t)(newline - position)); + if (!separator || separator == position || + separator == newline - 1) return -1; + if (memchr(separator + 1, '\t', + (size_t)(newline - separator - 1)) != NULL) + return -1; + if (memchr(position, '\r', (size_t)(newline - position)) || + memchr(position, '\0', (size_t)(newline - position))) + return -1; + *separator = '\0'; + *newline = '\0'; + int matched = 0; + for (size_t i = 0; i < RK_COUNT; ++i) { + if (strcmp(receipt_names[i], position) == 0) { + if (fields[i]) return -1; + fields[i] = separator + 1; + matched = 1; + break; + } + } + if (!matched || ++found > RK_COUNT) return -1; + position = newline + 1; + } + if (found != RK_COUNT || + strcmp(fields[RK_SCHEMA], "ib-icu-arena-v1") != 0 || + strcmp(fields[RK_REQUEST], request_id) != 0 || + strcmp(fields[RK_TRANSPORT], + "icu-app-process-via-binder-shizuku") != 0 || + strcmp(fields[RK_ARENA], "responses.arena") != 0 || + strcmp(fields[RK_REOPEN], "MATCH") != 0 || + strcmp(fields[RK_NAVIGATION], "NO") != 0) + return -1; + + /* v1 knows only that ICU emitted body bytes, not that HTML/navigation + * may be displayed as a verified HTTP response. */ + if (strcmp(fields[RK_RESOLVED_URL], "UNOBSERVED") != 0 || + strcmp(fields[RK_STATUS], "UNOBSERVED") != 0 || + strcmp(fields[RK_MIME], "UNOBSERVED") != 0) + return -1; + + uint64_t begin, finish, amount; + if (parse_uint(fields[RK_OFFSET], &begin) != 0 || + parse_uint(fields[RK_END], &finish) != 0 || + parse_uint(fields[RK_LENGTH], &amount) != 0 || + amount == 0 || amount > IB_READER_SOURCE_MAX_BYTES || + begin > finish || finish - begin != amount) + return -1; + + const char *hash = fields[RK_SHA256]; + if (strlen(hash) != 64U) return -1; + for (size_t i = 0; i < 64; ++i) + if (!((hash[i] >= '0' && hash[i] <= '9') || + (hash[i] >= 'a' && hash[i] <= 'f'))) return -1; + + *offset = begin; + *length = (uint32_t)amount; + memcpy(sha256, hash, 65U); + return 0; +} + +static int read_manifest(int root_fd, const char *request_id, + uint64_t *offset, uint32_t *length, char sha256[65]) { + int dir = -1, manifest = -1, result = -1; + char filename[IB_READER_REQUEST_ID_CAP + 5U]; + struct stat info; + char content[RECEIPT_MAX_BYTES + 1U]; + int n = snprintf(filename, sizeof(filename), "%s.tsv", request_id); + if (n < 1 || (size_t)n >= sizeof(filename)) return -1; + dir = openat(root_fd, "receipts", + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (dir < 0) goto done; + manifest = openat(dir, filename, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + if (manifest < 0 || fstat(manifest, &info) != 0 || + !S_ISREG(info.st_mode) || info.st_size <= 0 || + info.st_size > RECEIPT_MAX_BYTES || + info.st_uid != geteuid() || info.st_nlink != 1) + goto done; + size_t size = (size_t)info.st_size; + size_t got = 0; + while (got < size) { + ssize_t amount = pread(manifest, content + got, size - got, (off_t)got); + if (amount < 0 && errno == EINTR) continue; + if (amount <= 0) goto done; + got += (size_t)amount; + } + content[size] = '\0'; + result = parse_receipt(content, size, request_id, offset, length, sha256); +done: + if (manifest >= 0) close(manifest); + if (dir >= 0) close(dir); + return result; +} + +static int read_committed_bytes(int root_fd, uint64_t offset, uint32_t length, + unsigned char *bytes) { + int lock = -1, used = -1, arena = -1, result = -1; + struct stat info; + char pointer[64]; + lock = openat(root_fd, "responses.arena.lock", + O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + if (lock < 0 || flock(lock, LOCK_SH) != 0) goto done; + used = openat(root_fd, "responses.arena.used", + O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + arena = openat(root_fd, "responses.arena", + O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + if (used < 0 || arena < 0 || + fstat(used, &info) != 0 || !S_ISREG(info.st_mode)) + goto done; + ssize_t pointer_bytes = pread(used, pointer, sizeof(pointer) - 1U, 0); + if (pointer_bytes <= 1 || pointer_bytes >= (ssize_t)sizeof(pointer) - 1 || + pointer[pointer_bytes - 1] != '\n') + goto done; + pointer[pointer_bytes - 1] = '\0'; + uint64_t committed = 0; + if (parse_uint(pointer, &committed) != 0 || + fstat(arena, &info) != 0 || !S_ISREG(info.st_mode) || + info.st_uid != geteuid() || info.st_size < 0 || + committed > (uint64_t)info.st_size || + offset > committed || length > committed - offset) + goto done; + + size_t filled = 0; + while (filled < length) { + size_t remaining = (size_t)length - filled; + if (remaining > 65536U) remaining = 65536U; + ssize_t amount = pread(arena, bytes + filled, remaining, + (off_t)(offset + filled)); + if (amount < 0 && errno == EINTR) continue; + if (amount <= 0) goto done; + filled += (size_t)amount; + } + result = 0; +done: + if (arena >= 0) close(arena); + if (used >= 0) close(used); + if (lock >= 0) close(lock); + return result; +} + +static void *write_source(void *argument) { + source_writer *writer = argument; + sigset_t blocked; + sigemptyset(&blocked); + sigaddset(&blocked, SIGPIPE); + (void)pthread_sigmask(SIG_BLOCK, &blocked, NULL); + + int64_t now = time_milliseconds(); + int64_t deadline = now < 0 ? 0 : now + SOURCE_WRITER_DEADLINE_MS; + size_t sent = 0; + while (sent < writer->length) { + now = time_milliseconds(); + if (now < 0 || now >= deadline) break; + struct pollfd poll_fd = { writer->writer_fd, POLLOUT, 0 }; + int remaining = (int)(deadline - now); + int status = poll(&poll_fd, 1, remaining); + if (status < 0 && errno == EINTR) continue; + if (status <= 0 || (poll_fd.revents & (POLLERR | POLLNVAL | POLLHUP))) + break; + size_t chunk = writer->length - sent; + if (chunk > 65536U) chunk = 65536U; + ssize_t count = write(writer->writer_fd, writer->bytes + sent, chunk); + if (count < 0 && (errno == EINTR || errno == EAGAIN)) continue; + if (count <= 0) break; + sent += (size_t)count; + } + close(writer->writer_fd); + free(writer->bytes); + free(writer); + atomic_fetch_sub(&source_writers, 1U); + return NULL; +} + +int ib_arena_source_owner_init(ib_arena_source_owner *owner, + const char *trusted_private_root) { + if (!owner || !trusted_private_root || trusted_private_root[0] != '/') + return -1; + size_t length = strnlen(trusted_private_root, + IB_ARENA_PRIVATE_ROOT_CAP); + if (length == 0 || length >= IB_ARENA_PRIVATE_ROOT_CAP) + return -1; + memcpy(owner->private_root, trusted_private_root, length + 1U); + atomic_init(&owner->wanted_navigation_generation, 0); + return 0; +} + +void ib_arena_source_owner_want(ib_arena_source_owner *owner, + uint64_t navigation_generation) { + if (owner) + atomic_store(&owner->wanted_navigation_generation, + navigation_generation); +} + +int ib_arena_source_owner_open(ib_arena_source_owner *owner, + const char *request_id, + uint64_t navigation_generation, + ib_reader_source_ticket *ticket, + int *read_fd) { + if (read_fd) *read_fd = -1; + if (!owner || !read_fd || !safe_request_id(request_id) || + navigation_generation == 0 || + navigation_generation != + atomic_load(&owner->wanted_navigation_generation)) + return -1; + + int root = -1, result = -1, descriptor[2] = {-1, -1}; + uint64_t offset = 0; + uint32_t length = 0; + char expected[65], observed[65]; + unsigned char *bytes = NULL; + source_writer *writer = NULL; + pthread_t thread; + struct stat info; + + root = open(owner->private_root, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (root < 0 || fstat(root, &info) != 0 || + !S_ISDIR(info.st_mode) || info.st_uid != geteuid() || + (info.st_mode & 077U) != 0) goto done; + if (read_manifest(root, request_id, &offset, &length, expected) != 0) + goto done; + bytes = malloc(length); + if (!bytes || read_committed_bytes(root, offset, length, bytes) != 0) + goto done; + ib_reader_sha256_hex(bytes, length, observed); + if (strcmp(expected, observed) != 0) goto done; + if (navigation_generation != + atomic_load(&owner->wanted_navigation_generation)) goto done; + + if (atomic_fetch_add(&source_writers, 1U) >= + SOURCE_MAX_CONCURRENT_WRITERS) { + atomic_fetch_sub(&source_writers, 1U); + goto done; + } + if (pipe2(descriptor, O_CLOEXEC | O_NONBLOCK) != 0) { + atomic_fetch_sub(&source_writers, 1U); + goto done; + } + /* The consumer receives a blocking read descriptor but the writer + * stays nonblocking so unresponsive readers cannot accumulate threads. */ + int flags = fcntl(descriptor[0], F_GETFL); + if (flags < 0 || + fcntl(descriptor[0], F_SETFL, flags & ~O_NONBLOCK) != 0) { + atomic_fetch_sub(&source_writers, 1U); + goto done; + } + writer = calloc(1, sizeof(*writer)); + if (!writer) { + atomic_fetch_sub(&source_writers, 1U); + goto done; + } + writer->bytes = bytes; + writer->length = length; + writer->writer_fd = descriptor[1]; + if (pthread_create(&thread, NULL, write_source, writer) != 0) { + writer->bytes = NULL; /* caller still owns bytes */ + free(writer); + writer = NULL; + atomic_fetch_sub(&source_writers, 1U); + goto done; + } + (void)pthread_detach(thread); + bytes = NULL; + writer = NULL; + descriptor[1] = -1; + if (ticket) { + memset(ticket, 0, sizeof(*ticket)); + memcpy(ticket->request_id, request_id, strlen(request_id) + 1U); + ticket->navigation_generation = navigation_generation; + ticket->byte_count = length; + memcpy(ticket->sha256_hex, expected, sizeof(expected)); + } + *read_fd = descriptor[0]; + descriptor[0] = -1; + result = 0; +done: + if (descriptor[0] >= 0) close(descriptor[0]); + if (descriptor[1] >= 0) close(descriptor[1]); + if (root >= 0) close(root); + free(bytes); + return result; +} + +int ib_arena_source_owner_open_binder(void *owner, const char *request_id, + uint64_t navigation_generation, + int *read_fd) { + return ib_arena_source_owner_open((ib_arena_source_owner *)owner, + request_id, navigation_generation, + NULL, read_fd); +} From 53c93d730fc8f6151b6e59131ba85dd71d4e8aaf Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:25:46 -0400 Subject: [PATCH 20/49] Test receipt-bound app-private Arena to native PFD source publication --- tests/test_android_arena_source_owner.c | 157 ++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 tests/test_android_arena_source_owner.c diff --git a/tests/test_android_arena_source_owner.c b/tests/test_android_arena_source_owner.c new file mode 100644 index 00000000..ebf35118 --- /dev/null +++ b/tests/test_android_arena_source_owner.c @@ -0,0 +1,157 @@ +#define _POSIX_C_SOURCE 200809L +#include "../native/android/ib_arena_source_owner.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static unsigned char expected_source[] = {'I','B',0,'C','6','7',255,'\n'}; +static const char *request_id = "verified"; +static const char *name = "receipts/verified.tsv"; + +static void full_file(const char *path, unsigned char **bytes, size_t *length) { + FILE *file = fopen(path, "rb"); + assert(file != NULL); + assert(fseek(file, 0, SEEK_END) == 0); + long size = ftell(file); + assert(size > 0 && size < 8192); + assert(fseek(file, 0, SEEK_SET) == 0); + *bytes = malloc((size_t)size); + assert(*bytes != NULL); + assert(fread(*bytes, 1, (size_t)size, file) == (size_t)size); + assert(fclose(file) == 0); + *length = (size_t)size; +} + +static void overwrite(const char *path, const unsigned char *bytes, size_t count) { + FILE *file = fopen(path, "wb"); + assert(file != NULL); + assert(fwrite(bytes, 1, count, file) == count); + assert(fflush(file) == 0); + assert(fclose(file) == 0); +} + +static void preserved(const ib_reader_source *reader, + const char *id, uint64_t generation) { + assert(reader->bytes != NULL); + assert(reader->byte_count == sizeof(expected_source)); + assert(memcmp(reader->bytes, expected_source, sizeof(expected_source)) == 0); + assert(reader->published_navigation_generation == generation); + assert(strcmp(reader->request_id, id) == 0); +} + +static void accept(ib_arena_source_owner *owner, ib_reader_source *reader, + uint64_t generation) { + ib_reader_source_ticket ticket = {0}; + int fd = -1; + assert(ib_arena_source_owner_open(owner, request_id, generation, + &ticket, &fd) == 0); + assert(fd >= 0); + assert(ticket.byte_count == sizeof(expected_source)); + assert(ticket.navigation_generation == generation); + assert(strcmp(ticket.request_id, request_id) == 0); + char local_digest[65]; + ib_reader_sha256_hex(expected_source, sizeof(expected_source), local_digest); + assert(strcmp(ticket.sha256_hex, local_digest) == 0); + assert(ib_reader_source_accept_fd(reader, &ticket, fd) + == IB_READER_PFD_ACCEPTED); + close(fd); + preserved(reader, request_id, generation); +} + +int main(int argc, char **argv) { + assert(argc == 2); + char private_root[4096], receipt[4096], arena[4096], pointer[4096]; + assert(snprintf(private_root, sizeof(private_root), "%s", argv[1]) + < (int)sizeof(private_root)); + assert(snprintf(receipt, sizeof(receipt), "%s/%s", private_root, name) + < (int)sizeof(receipt)); + assert(snprintf(arena, sizeof(arena), "%s/responses.arena", private_root) + < (int)sizeof(arena)); + assert(snprintf(pointer, sizeof(pointer), "%s/responses.arena.used", private_root) + < (int)sizeof(pointer)); + + ib_arena_source_owner owner; + assert(ib_arena_source_owner_init(&owner, private_root) == 0); + ib_reader_source reader; + ib_reader_source_init(&reader); + ib_arena_source_owner_want(&owner, 1); + ib_reader_source_want(&reader, 1); + accept(&owner, &reader, 1); + + int fd = -1; + assert(ib_arena_source_owner_open_binder(&owner, request_id, 2, &fd) != 0); + assert(fd == -1); + assert(ib_arena_source_owner_open(&owner, "../bad", 1, NULL, &fd) != 0); + preserved(&reader, request_id, 1); + + unsigned char *original = NULL; + size_t count = 0; + full_file(receipt, &original, &count); + unsigned char *damaged = malloc(count); + assert(damaged != NULL); + memcpy(damaged, original, count); + char *digest = strstr((char *)damaged, "sha256\t"); + assert(digest != NULL); + digest += strlen("sha256\t"); + *digest = *digest == '0' ? '1' : '0'; + overwrite(receipt, damaged, count); + assert(ib_arena_source_owner_open(&owner, request_id, 1, NULL, &fd) != 0); + assert(fd == -1); + preserved(&reader, request_id, 1); + overwrite(receipt, original, count); + + /* Last-good retained when Arena source bytes no longer match receipt. */ + int file = open(arena, O_RDWR); + assert(file >= 0); + unsigned char first = 0; + assert(pread(file, &first, 1, 0) == 1); + unsigned char changed = (unsigned char)(first ^ 0xffU); + assert(pwrite(file, &changed, 1, 0) == 1); + assert(ib_arena_source_owner_open(&owner, request_id, 1, NULL, &fd) != 0); + assert(fd == -1); + preserved(&reader, request_id, 1); + assert(pwrite(file, &first, 1, 0) == 1); + close(file); + + unsigned char *saved_pointer = NULL; + size_t used_count = 0; + full_file(pointer, &saved_pointer, &used_count); + overwrite(pointer, (const unsigned char *)"999999999\n", 10); + assert(ib_arena_source_owner_open(&owner, request_id, 1, NULL, &fd) != 0); + assert(fd == -1); + overwrite(pointer, saved_pointer, used_count); + free(saved_pointer); + + /* A substituted symlink or world-readable private root is refused. */ + char alias[4096]; + assert(snprintf(alias, sizeof(alias), "%s-symlink", private_root) + < (int)sizeof(alias)); + assert(symlink(private_root, alias) == 0); + ib_arena_source_owner linked_owner; + assert(ib_arena_source_owner_init(&linked_owner, alias) == 0); + ib_arena_source_owner_want(&linked_owner, 1); + assert(ib_arena_source_owner_open(&linked_owner, request_id, 1, NULL, &fd) != 0); + assert(unlink(alias) == 0); + assert(chmod(private_root, 0755) == 0); + assert(ib_arena_source_owner_open(&owner, request_id, 1, NULL, &fd) != 0); + assert(chmod(private_root, 0700) == 0); + + ib_arena_source_owner_want(&owner, 2); + ib_reader_source_want(&reader, 2); + accept(&owner, &reader, 2); + preserved(&reader, request_id, 2); + ib_reader_source_clear(&reader); + free(original); + free(damaged); + puts("PASS: canonical appendfat receipt + commit pointer, binary PFD, end-to-end double SHA, stale/URL/digest/byte/permission/symlink/corrupt-pointer refusal and recovery"); + puts("NOT_RUN: Android private Binder transaction, actual Shizuku/ICU network, Idric reader screen or physical C67"); + return 0; +} From cbdb7c075525d1a9b66fd11719cf4acb1cb2c2cc Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:26:14 -0400 Subject: [PATCH 21/49] Exercise native private PFD handoff with canonical appendfat Arena --- tests/test_android_arena_source_owner.sh | 44 ++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 tests/test_android_arena_source_owner.sh diff --git a/tests/test_android_arena_source_owner.sh b/tests/test_android_arena_source_owner.sh new file mode 100644 index 00000000..5d6303fc --- /dev/null +++ b/tests/test_android_arena_source_owner.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# Source owner test consumes the real canonical appendfat_arena, not a mock. +set -eu +arena_tool=${IB_APPEND_ARENA:-} +helper=${IB_TEST_VERIFIED_ARENA_PIPE:-} +slice=${IB_ARENA_SLICE:-} +test -n "$arena_tool" && test -x "$arena_tool" && +test -n "$helper" && test -x "$helper" && +test -n "$slice" && test -x "$slice" || { + echo 'canonical Arena, compiled reader and test helper required' >&2 + exit 2 +} +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT HUP INT TERM +mkdir -m 700 "$work/private" +"$arena_tool" create "$work/private/responses.arena" 16777216 > "$work/create.txt" +printf 'IB\000C67\377\n' > "$work/fixture.bin" +test "$(wc -c < "$work/fixture.bin" | tr -d '[:space:]')" -eq 8 +append=$("$arena_tool" append "$work/private/responses.arena" "$work/fixture.bin") +old=$(printf '%s\n' "$append" | sed -n 's/^APPENDED old_used=\([0-9][0-9]*\) new_used=[0-9][0-9]* capacity_bytes=[0-9][0-9]*$/\1/p') +new=$(printf '%s\n' "$append" | sed -n 's/^APPENDED old_used=[0-9][0-9]* new_used=\([0-9][0-9]*\) capacity_bytes=[0-9][0-9]*$/\1/p') +test "$old" = 0 && test "$new" = 8 +"$slice" slice "$work/private/responses.arena" "$old" 8 > "$work/readback" +cmp "$work/fixture.bin" "$work/readback" +digest=$(sha256sum "$work/fixture.bin" | cut -d ' ' -f 1) +mkdir -m 700 "$work/private/receipts" +{ + printf 'schema\tib-icu-arena-v1\n' + printf 'request_id\tverified\n' + printf 'requested_url\thttps://example.com/\n' + printf 'resolved_url\tUNOBSERVED\n' + printf 'http_status\tUNOBSERVED\n' + printf 'mime_type\tUNOBSERVED\n' + printf 'transport\ticu-app-process-via-binder-shizuku\n' + printf 'arena_name\tresponses.arena\n' + printf 'arena_offset\t%s\n' "$old" + printf 'arena_end\t%s\n' "$new" + printf 'byte_count\t8\n' + printf 'sha256\t%s\n' "$digest" + printf 'reopen_check\tMATCH\n' + printf 'navigation_admitted\tNO\n' +} > "$work/private/receipts/verified.tsv" +"$helper" "$work/private" +printf '%s\n' 'checked_arena=actual_appends' 'reader_body_sha256=verified' From 1b896a3518cfc01b6ab1d2f0c0b9db15d5bd247b Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:26:20 -0400 Subject: [PATCH 22/49] Keep hostile receipt fixture zero-terminated during mutation --- tests/test_android_arena_source_owner.c | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_android_arena_source_owner.c b/tests/test_android_arena_source_owner.c index ebf35118..257139f0 100644 --- a/tests/test_android_arena_source_owner.c +++ b/tests/test_android_arena_source_owner.c @@ -23,9 +23,10 @@ static void full_file(const char *path, unsigned char **bytes, size_t *length) { long size = ftell(file); assert(size > 0 && size < 8192); assert(fseek(file, 0, SEEK_SET) == 0); - *bytes = malloc((size_t)size); + *bytes = malloc((size_t)size + 1U); assert(*bytes != NULL); assert(fread(*bytes, 1, (size_t)size, file) == (size_t)size); + (*bytes)[size] = '\0'; assert(fclose(file) == 0); *length = (size_t)size; } @@ -95,9 +96,9 @@ int main(int argc, char **argv) { unsigned char *original = NULL; size_t count = 0; full_file(receipt, &original, &count); - unsigned char *damaged = malloc(count); + unsigned char *damaged = malloc(count + 1U); assert(damaged != NULL); - memcpy(damaged, original, count); + memcpy(damaged, original, count + 1U); char *digest = strstr((char *)damaged, "sha256\t"); assert(digest != NULL); digest += strlen("sha256\t"); From c343649f2609fe3d64d1e35f086e788b0bbe21fe Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:26:42 -0400 Subject: [PATCH 23/49] Test real canonical Arena-to-private PFD callback and cross-build ARM32/ARM64 --- .github/workflows/ib-c67-reader-pfd.yml | 37 ++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ib-c67-reader-pfd.yml b/.github/workflows/ib-c67-reader-pfd.yml index e6f97809..f21417d5 100644 --- a/.github/workflows/ib-c67-reader-pfd.yml +++ b/.github/workflows/ib-c67-reader-pfd.yml @@ -7,6 +7,10 @@ on: - 'native/android/ib_reader_pfd.h' - 'native/android/ib_arena_pfd_provider.c' - 'native/android/ib_arena_pfd_provider.h' + - 'native/android/ib_arena_source_owner.c' + - 'native/android/ib_arena_source_owner.h' + - 'tests/test_android_arena_source_owner.c' + - 'tests/test_android_arena_source_owner.sh' - 'tests/test_android_reader_pfd.c' - '.github/workflows/ib-c67-reader-pfd.yml' workflow_dispatch: @@ -20,6 +24,16 @@ jobs: steps: - name: Checkout pinned PR source uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Checkout immutable canonical appendfat Arena + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: isomorphisms/sd-card-append-fat + ref: 05a0cb7246c5a9f46abfbe92925812e7b607f454 + path: .deps/appendfat + persist-credentials: false + - name: Verify canonical Arena source pin + run: | + test "$(git -C .deps/appendfat rev-parse HEAD)" = 05a0cb7246c5a9f46abfbe92925812e7b607f454 - name: Host C11 two-process descriptor tests run: | set -eu @@ -31,6 +45,26 @@ jobs: grep -F 'NOT_RUN: Android Binder service' build/c67-pfd/host/receipt.txt sha256sum build/c67-pfd/host/test-reader-pfd \ > build/c67-pfd/host/sha256.txt + - name: Read and serve actual canonical Arena bytes as a scoped pipe + run: | + set -eu + cc -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \ + .deps/appendfat/lib/appendfat_arena.c \ + .deps/appendfat/tools/appendfat_arena.c -o build/c67-pfd/host/appendfat_arena + cc -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \ + native/android/ib_arena_slice.c -o build/c67-pfd/host/ib-arena-slice + cc -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror -pthread \ + native/android/ib_reader_pfd.c native/android/ib_arena_source_owner.c \ + tests/test_android_arena_source_owner.c \ + -o build/c67-pfd/host/test-arena-pfd + env IB_APPEND_ARENA="$GITHUB_WORKSPACE/build/c67-pfd/host/appendfat_arena" \ + IB_ARENA_SLICE="$GITHUB_WORKSPACE/build/c67-pfd/host/ib-arena-slice" \ + IB_TEST_VERIFIED_ARENA_PIPE="$GITHUB_WORKSPACE/build/c67-pfd/host/test-arena-pfd" \ + sh tests/test_android_arena_source_owner.sh \ + | tee build/c67-pfd/host/arena-source-receipt.txt + grep -F 'checked_arena=actual_appends' build/c67-pfd/host/arena-source-receipt.txt + sha256sum build/c67-pfd/host/test-arena-pfd \ + > build/c67-pfd/host/arena-test-sha256.txt - name: Cross-build real Android libbinder_ndk PFD reader on both ABIs run: | set -eu @@ -44,7 +78,8 @@ jobs: "$toolchain/$target-clang" -std=c11 -O2 -Wall -Wextra \ -Wpedantic -Werror -fPIC -shared native/android/ib_reader_pfd.c \ native/android/ib_arena_pfd_provider.c \ - -lbinder_ndk -o "build/c67-pfd/$abi/libib_reader_pfd.so" + native/android/ib_arena_source_owner.c \ + -pthread -lbinder_ndk -o "build/c67-pfd/$abi/libib_reader_pfd.so" "$toolchain/llvm-readelf" -h "build/c67-pfd/$abi/libib_reader_pfd.so" \ > "build/c67-pfd/$abi/elf.txt" grep -F "Machine:" "build/c67-pfd/$abi/elf.txt" | grep -F "$machine" From f9fd28a02729c17f234bcd30759cd04dbcf025c4 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:28:34 -0400 Subject: [PATCH 24/49] Define native app host assembly joining private Arena and Binder provider --- native/android/ib_reader_host_assembly.h | 33 ++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 native/android/ib_reader_host_assembly.h diff --git a/native/android/ib_reader_host_assembly.h b/native/android/ib_reader_host_assembly.h new file mode 100644 index 00000000..dd101d03 --- /dev/null +++ b/native/android/ib_reader_host_assembly.h @@ -0,0 +1,33 @@ +#ifndef IB_ANDROID_READER_HOST_ASSEMBLY_H +#define IB_ANDROID_READER_HOST_ASSEMBLY_H + +#include "ib_arena_source_owner.h" +#include "ib_arena_pfd_provider.h" + +#if defined(__ANDROID__) && __ANDROID_API__ >= 29 + +/* App host owns the memory and must keep it stable until the local Binder + * object and all in-flight calls are gone. Browser event dispatch, not the + * renderer or Shizuku, advances the expected source generation. */ +typedef struct { + ib_arena_source_owner source; + ib_arena_pfd_provider descriptor_provider; + AIBinder *binder; +} ib_reader_host_assembly; + +/* The source owner runs at the app UID and uses its existing private Arena. + * allowed_reader_uid must be obtained from trusted Android package identity. + * Neither shell UID 2000 nor a caller-controlled UID is accepted. */ +int ib_reader_host_assembly_init(ib_reader_host_assembly *assembly, + const char *trusted_private_arena_root, + uid_t allowed_reader_uid); +void ib_reader_host_assembly_want(ib_reader_host_assembly *assembly, + uint64_t navigation_generation); + +/* Host must first stop Binder calls and release its external references. + * Caller must not free assembly until the Binder object's final destruction. */ +void ib_reader_host_assembly_release_local_reference( + ib_reader_host_assembly *assembly); +#endif + +#endif From e111e33dac4bcd732a314ad68e89d67d3bb6cc11 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:28:41 -0400 Subject: [PATCH 25/49] Connect native Binder service to canonical source-owner callback --- native/android/ib_reader_host_assembly.c | 37 ++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 native/android/ib_reader_host_assembly.c diff --git a/native/android/ib_reader_host_assembly.c b/native/android/ib_reader_host_assembly.c new file mode 100644 index 00000000..5e01f349 --- /dev/null +++ b/native/android/ib_reader_host_assembly.c @@ -0,0 +1,37 @@ +#include "ib_reader_host_assembly.h" + +#if defined(__ANDROID__) && __ANDROID_API__ >= 29 + +#include + +int ib_reader_host_assembly_init(ib_reader_host_assembly *assembly, + const char *trusted_private_arena_root, + uid_t allowed_reader_uid) { + if (!assembly || allowed_reader_uid < (uid_t)10000) + return -1; + memset(assembly, 0, sizeof(*assembly)); + if (ib_arena_source_owner_init(&assembly->source, + trusted_private_arena_root) != 0) + return -1; + assembly->descriptor_provider.allowed_reader_uid = allowed_reader_uid; + assembly->descriptor_provider.open_verified_fd = + ib_arena_source_owner_open_binder; + assembly->descriptor_provider.source_owner = &assembly->source; + assembly->binder = + ib_arena_pfd_provider_new(&assembly->descriptor_provider); + return assembly->binder ? 0 : -1; +} + +void ib_reader_host_assembly_want(ib_reader_host_assembly *assembly, + uint64_t navigation_generation) { + if (assembly) + ib_arena_source_owner_want(&assembly->source, navigation_generation); +} + +void ib_reader_host_assembly_release_local_reference( + ib_reader_host_assembly *assembly) { + if (!assembly || !assembly->binder) return; + AIBinder_decStrong(assembly->binder); + assembly->binder = NULL; +} +#endif From 41085336e6a547b099ea97bf044658fbdc78a41f Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:28:49 -0400 Subject: [PATCH 26/49] Cross-build app-owned Binder+Arena native assembly for both Android ABIs --- .github/workflows/ib-c67-reader-pfd.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ib-c67-reader-pfd.yml b/.github/workflows/ib-c67-reader-pfd.yml index f21417d5..dfb6d3e5 100644 --- a/.github/workflows/ib-c67-reader-pfd.yml +++ b/.github/workflows/ib-c67-reader-pfd.yml @@ -9,6 +9,8 @@ on: - 'native/android/ib_arena_pfd_provider.h' - 'native/android/ib_arena_source_owner.c' - 'native/android/ib_arena_source_owner.h' + - 'native/android/ib_reader_host_assembly.c' + - 'native/android/ib_reader_host_assembly.h' - 'tests/test_android_arena_source_owner.c' - 'tests/test_android_arena_source_owner.sh' - 'tests/test_android_reader_pfd.c' @@ -79,6 +81,7 @@ jobs: -Wpedantic -Werror -fPIC -shared native/android/ib_reader_pfd.c \ native/android/ib_arena_pfd_provider.c \ native/android/ib_arena_source_owner.c \ + native/android/ib_reader_host_assembly.c \ -pthread -lbinder_ndk -o "build/c67-pfd/$abi/libib_reader_pfd.so" "$toolchain/llvm-readelf" -h "build/c67-pfd/$abi/libib_reader_pfd.so" \ > "build/c67-pfd/$abi/elf.txt" From 7a5d8d5a19816cb58bd1518f383124986c7091f1 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:30:00 -0400 Subject: [PATCH 27/49] Use pinned ICK for canonical appendfat Unicode C instead of host cc --- .github/workflows/ib-c67-reader-pfd.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ib-c67-reader-pfd.yml b/.github/workflows/ib-c67-reader-pfd.yml index dfb6d3e5..73dc7a0f 100644 --- a/.github/workflows/ib-c67-reader-pfd.yml +++ b/.github/workflows/ib-c67-reader-pfd.yml @@ -47,10 +47,15 @@ jobs: grep -F 'NOT_RUN: Android Binder service' build/c67-pfd/host/receipt.txt sha256sum build/c67-pfd/host/test-reader-pfd \ > build/c67-pfd/host/sha256.txt + - id: ick + uses: isomorphisms/ai-ci/ick-host@015cc7901ae0b3ad262b476f24e129b53c56db95 - name: Read and serve actual canonical Arena bytes as a scoped pipe + env: + ICK: ${{ steps.ick.outputs.compiler }} run: | set -eu - cc -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \ + test -x "$ICK" + "$ICK" -fno-link-libatomic -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \ .deps/appendfat/lib/appendfat_arena.c \ .deps/appendfat/tools/appendfat_arena.c -o build/c67-pfd/host/appendfat_arena cc -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \ From 3a56496e6987fa057e642c8a9970b2ac42c6877c Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:30:33 -0400 Subject: [PATCH 28/49] Accept numeric suffix in known Arena receipt keys such as sha256 --- bin/ib_android_arena_read.grease | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/ib_android_arena_read.grease b/bin/ib_android_arena_read.grease index ed377c54..d8bdf5e4 100644 --- a/bin/ib_android_arena_read.grease +++ b/bin/ib_android_arena_read.grease @@ -22,7 +22,7 @@ for entry in "$arena" "$arena.used" "$arena.lock"; do done test "$(wc -c < "$manifest" | tr -d '[:space:]')" -le 8192 || fail 'oversized receipt' awk -F '\t' ' - NF != 2 || $1 !~ /^[a-z_]+$/ || $2 == "" || /\r/ {bad=1} + NF != 2 || $1 !~ /^[a-z_][a-z_0-9]*$/ || $2 == "" || /\r/ {bad=1} { if (seen[$1]++) bad=1 } END { if (bad || NR != 14) exit 1 } ' "$manifest" || fail 'invalid receipt structure' From 197bd5db7978a14262617af76ab7b36e0198e6e1 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:30:56 -0400 Subject: [PATCH 29/49] Document source-owning private binder assembly and real appendfat host tests --- docs/c67-reader-pfd.md | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/docs/c67-reader-pfd.md b/docs/c67-reader-pfd.md index 7157f43b..d2b943e0 100644 --- a/docs/c67-reader-pfd.md +++ b/docs/c67-reader-pfd.md @@ -1,6 +1,6 @@ # IB C67: private Binder/PFD reader handoff (bounded receiver) -**Status:** native Binder provider/receiver contracts and independent host simulation only. +**Status:** native Binder provider/receiver, verified Arena source-owner callback and app-host assembly, with hosted fixture tests. No phone APK. This branch is stacked on IB PR #152, which stores checked ICU body bytes in the existing userspace appendfat Arena. **No URL-capable APK, provider service, Idriç screen connection, or physical C67 Binder execution is claimed.** @@ -47,8 +47,14 @@ Private Binder implementation contract, transaction code **1**: native/android/ib_arena_pfd_provider.{h,c} implement a narrowly UID-gated **serving** Binder object (the host supplies its verified-source callback). The object must be handed privately to the checked reader by the app host: -no global Binder service registration is used here. The callback that opens -the validated Arena receipt and populates its read-only pipe is still missing. +no global Binder service registration is used here. The callback is now implemented in native/android/ib_arena_source_owner.{h,c}: +it opens the app's existing 0700 Arena root, reads an immutable source receipt, +checks canonical .used and locked range, independently rehashes the complete +body, and emits an anonymous read-only pipe from a bounded writer thread. +It never returns the Arena's file descriptor. Host assembly wiring in +native/android/ib_reader_host_assembly.{h,c} binds that callback to the +private Binder provider object. **The app lifecycle has not yet instantiated +this assembly, and no Binder reference has been delivered to the Idriç DEX.** native/android/ib_reader_pfd.{h,c} implement the **receiving** C11 boundary. On Android API 29+ they use NDK Binder parcel transactions and @@ -75,18 +81,26 @@ over-limit tickets and stale navigation generations. **SCM_RIGHTS is a host stand-in for descriptor ownership, not a claim of tested Android Binder delivery or tested cross-UID Android authority.** -The independent Android CI lane cross-compiles the actual NDK Binder -calls for ARM32 and ARM64 and retains digests. Cross-compilation does +The second host test constructs a real pinned sd-card-append-fat Arena, +appends binary bytes using its canonical append command, publishes the +existing IB v1 receipt, then drives the actual C source owner and reader +verifiers end-to-end. It mutates receipts, source bytes, commit pointer, +generation, root permissions and path identity, requiring refusal and +last-good preservation. This is still Linux-hosted source admission. + +The independent Android CI lane cross-compiles actual NDK Binder calls, +source-owner assembly and pipe writer for ARM32 and ARM64 and retains digests. Cross-compilation does **not** prove the provider exists, the Idriç DEX can call this library, or that an Android Binder transaction has happened. ## Required next integration -1. Connect the native provider object to the existing Binder host. Implement - its checked-source callback, producing a fresh anonymous read-only pipe - only after canonical Arena verification. Resolve reader app UID from - trusted package identity. Keep shell UID 2000 outside reader authorization; - it cannot assume access to the app-private Arena. +1. Instantiate the newly connected native host assembly in the existing + Android application lifecycle, resolve the real reader UID from trusted + package identity and privately supply its Binder handle to the checked + Idriç DEX. The assembly cannot use root paths owned by Termux or shell: + add the separate authorized Shizuku-worker-to-app-private-Arena ingress + through scoped IPC. Shell UID 2000 cannot read the app's private Arena. 2. Bind the native C receiver to the **existing checked Idriç Material reader** (#129/#147), with qualified DEX/native FFI and lifecycle. Do not introduce Java/Kotlin application logic or a replacement UI. From a9702dfa99d3e96f89122eadfb36279b2c6439cf Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:31:56 -0400 Subject: [PATCH 30/49] Do not let Arena writer locks block the reader Binder dispatch indefinitely --- native/android/ib_arena_source_owner.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/android/ib_arena_source_owner.c b/native/android/ib_arena_source_owner.c index 5dd6abfc..0562000d 100644 --- a/native/android/ib_arena_source_owner.c +++ b/native/android/ib_arena_source_owner.c @@ -187,7 +187,7 @@ static int read_committed_bytes(int root_fd, uint64_t offset, uint32_t length, char pointer[64]; lock = openat(root_fd, "responses.arena.lock", O_RDONLY | O_NOFOLLOW | O_CLOEXEC); - if (lock < 0 || flock(lock, LOCK_SH) != 0) goto done; + if (lock < 0 || flock(lock, LOCK_SH | LOCK_NB) != 0) goto done; used = openat(root_fd, "responses.arena.used", O_RDONLY | O_NOFOLLOW | O_CLOEXEC); arena = openat(root_fd, "responses.arena", From 56fec2ad9da081de040a7d71feae9d63b757c53b Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:32:03 -0400 Subject: [PATCH 31/49] Create Binder class once safely across concurrent IPC calls --- native/android/ib_arena_pfd_provider.c | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/native/android/ib_arena_pfd_provider.c b/native/android/ib_arena_pfd_provider.c index d993ddd4..19a57982 100644 --- a/native/android/ib_arena_pfd_provider.c +++ b/native/android/ib_arena_pfd_provider.c @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -112,16 +113,18 @@ static binder_status_t transact(AIBinder *binder, transaction_code_t code, return status; } +static pthread_once_t binder_class_once = PTHREAD_ONCE_INIT; +static AIBinder_Class *binder_class = NULL; + +static void initialize_binder_class(void) { + binder_class = AIBinder_Class_define( + "org.isomorphisms.ib.ArenaCommittedSource.v1", + created, destroyed, transact + ); +} + const AIBinder_Class *ib_arena_pfd_provider_class(void) { - /* Initialized by the host before concurrent Binder dispatch. A reader - * proxy associates this same interface descriptor before transacting. */ - static AIBinder_Class *binder_class = NULL; - if (!binder_class) { - binder_class = AIBinder_Class_define( - "org.isomorphisms.ib.ArenaCommittedSource.v1", - created, destroyed, transact - ); - } + (void)pthread_once(&binder_class_once, initialize_binder_class); return binder_class; } From 2e4a672f01f1a8c71311dd8f9ededa7caf0c7d98 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:32:34 -0400 Subject: [PATCH 32/49] Add fast synthetic host acceptance independent of ICK bootstrap --- ...st_android_arena_source_owner_synthetic.sh | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/test_android_arena_source_owner_synthetic.sh diff --git a/tests/test_android_arena_source_owner_synthetic.sh b/tests/test_android_arena_source_owner_synthetic.sh new file mode 100644 index 00000000..c6bf15de --- /dev/null +++ b/tests/test_android_arena_source_owner_synthetic.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Fast host contract test: synthetic pointer+receipt, NOT canonical appendfat. +set -eu +helper=${IB_TEST_VERIFIED_ARENA_PIPE:-} +test -n "$helper" && test -x "$helper" || exit 2 +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT HUP INT TERM +mkdir -m 700 "$work/private" "$work/private/receipts" +printf 'IB\000C67\377\n' > "$work/fixture.bin" +truncate -s 16777216 "$work/private/responses.arena" +dd if="$work/fixture.bin" of="$work/private/responses.arena" conv=notrunc status=none +printf '8\n' > "$work/private/responses.arena.used" +: > "$work/private/responses.arena.lock" +digest=$(sha256sum "$work/fixture.bin" | cut -d ' ' -f 1) +{ + printf 'schema\tib-icu-arena-v1\n' + printf 'request_id\tverified\n' + printf 'requested_url\thttps://example.com/\n' + printf 'resolved_url\tUNOBSERVED\n' + printf 'http_status\tUNOBSERVED\n' + printf 'mime_type\tUNOBSERVED\n' + printf 'transport\ticu-app-process-via-binder-shizuku\n' + printf 'arena_name\tresponses.arena\n' + printf 'arena_offset\t0\n' + printf 'arena_end\t8\n' + printf 'byte_count\t8\n' + printf 'sha256\t%s\n' "$digest" + printf 'reopen_check\tMATCH\n' + printf 'navigation_admitted\tNO\n' +} > "$work/private/receipts/verified.tsv" +"$helper" "$work/private" +printf 'fixture\tsynthetic-pointer-not-canonical-appendfat\n' From 1f369df1d46603912743a894e6f278632ca98b7b Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:32:42 -0400 Subject: [PATCH 33/49] Run fast host PFD admission tests separately from expensive pinned ICK qualification --- .github/workflows/ib-c67-reader-pfd.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/ib-c67-reader-pfd.yml b/.github/workflows/ib-c67-reader-pfd.yml index 73dc7a0f..37eaec87 100644 --- a/.github/workflows/ib-c67-reader-pfd.yml +++ b/.github/workflows/ib-c67-reader-pfd.yml @@ -13,6 +13,7 @@ on: - 'native/android/ib_reader_host_assembly.h' - 'tests/test_android_arena_source_owner.c' - 'tests/test_android_arena_source_owner.sh' + - 'tests/test_android_arena_source_owner_synthetic.sh' - 'tests/test_android_reader_pfd.c' - '.github/workflows/ib-c67-reader-pfd.yml' workflow_dispatch: @@ -21,6 +22,25 @@ permissions: contents: read jobs: + pfd-host-fast: + runs-on: ubuntu-24.04 + steps: + - name: Checkout candidate source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Compile native receiver and fake-boundary source owner + run: | + set -eu + mkdir -p build/c67-pfd/fast + cc -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror -pthread \ + native/android/ib_reader_pfd.c native/android/ib_arena_source_owner.c \ + tests/test_android_arena_source_owner.c \ + -o build/c67-pfd/fast/test-arena-pfd + env IB_TEST_VERIFIED_ARENA_PIPE="$GITHUB_WORKSPACE/build/c67-pfd/fast/test-arena-pfd" \ + sh tests/test_android_arena_source_owner_synthetic.sh \ + | tee build/c67-pfd/fast/receipt.txt + grep -F 'fixture' build/c67-pfd/fast/receipt.txt | grep -F 'synthetic-pointer-not-canonical-appendfat' + sha256sum build/c67-pfd/fast/test-arena-pfd > build/c67-pfd/fast/sha256.txt + pfd-source-admission: runs-on: ubuntu-24.04 steps: From 26f215a24848c1d968e4a0cc5759ad0419690e82 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:33:57 -0400 Subject: [PATCH 34/49] Bind source length and SHA ticket to returned private Binder descriptor --- native/android/ib_arena_pfd_provider.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/native/android/ib_arena_pfd_provider.h b/native/android/ib_arena_pfd_provider.h index 8fe520cf..5037c4fa 100644 --- a/native/android/ib_arena_pfd_provider.h +++ b/native/android/ib_arena_pfd_provider.h @@ -2,6 +2,7 @@ #define IB_ANDROID_ARENA_PFD_PROVIDER_H #include +#include "ib_reader_pfd.h" #include #if defined(__ANDROID__) && __ANDROID_API__ >= 29 @@ -13,6 +14,7 @@ typedef int (*ib_open_committed_source)(void *source_owner, const char *request_id, uint64_t navigation_generation, + ib_reader_source_ticket *verified_ticket, int *owned_read_fd); typedef struct { From 5afbc6b1271414352115a32d2b1573a92e6e65c8 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:34:02 -0400 Subject: [PATCH 35/49] Return authenticated receipt metadata with PFD callback --- native/android/ib_arena_source_owner.h | 1 + 1 file changed, 1 insertion(+) diff --git a/native/android/ib_arena_source_owner.h b/native/android/ib_arena_source_owner.h index ba311abb..26e93159 100644 --- a/native/android/ib_arena_source_owner.h +++ b/native/android/ib_arena_source_owner.h @@ -34,6 +34,7 @@ int ib_arena_source_owner_open(ib_arena_source_owner *owner, /* Adapter for ib_arena_pfd_provider.open_verified_fd. */ int ib_arena_source_owner_open_binder(void *owner, const char *request_id, uint64_t navigation_generation, + ib_reader_source_ticket *verified_ticket, int *read_fd); #endif From 1d9e0ad9eb74cc9475fab109188b7be1a024e43f Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:34:09 -0400 Subject: [PATCH 36/49] Deliver receipt-derived ticket alongside private descriptor --- native/android/ib_arena_source_owner.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/native/android/ib_arena_source_owner.c b/native/android/ib_arena_source_owner.c index 0562000d..a7534a7a 100644 --- a/native/android/ib_arena_source_owner.c +++ b/native/android/ib_arena_source_owner.c @@ -371,8 +371,9 @@ int ib_arena_source_owner_open(ib_arena_source_owner *owner, int ib_arena_source_owner_open_binder(void *owner, const char *request_id, uint64_t navigation_generation, + ib_reader_source_ticket *verified_ticket, int *read_fd) { return ib_arena_source_owner_open((ib_arena_source_owner *)owner, request_id, navigation_generation, - NULL, read_fd); + verified_ticket, read_fd); } From 18b58fb6f732decd39472fa614e409e8e7269621 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:34:20 -0400 Subject: [PATCH 37/49] Return bounded source length and digest inside authenticated PFD reply --- native/android/ib_arena_pfd_provider.c | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/native/android/ib_arena_pfd_provider.c b/native/android/ib_arena_pfd_provider.c index 19a57982..0f9a26d7 100644 --- a/native/android/ib_arena_pfd_provider.c +++ b/native/android/ib_arena_pfd_provider.c @@ -89,14 +89,34 @@ static binder_status_t transact(AIBinder *binder, transaction_code_t code, return respond(out, 2); int descriptor = -1; + ib_reader_source_ticket ticket = {0}; int result = provider->open_verified_fd(provider->source_owner, request.request_id, (uint64_t)generation, + &ticket, &descriptor); if (result != 0 || descriptor < 0) { if (descriptor >= 0) close(descriptor); return respond(out, 3); } + /* The callback must return metadata from the exact verified receipt. + * Never trust a caller's URL or claim navigation based on source bytes. */ + if (strcmp(ticket.request_id, request.request_id) != 0 || + ticket.navigation_generation != (uint64_t)generation || + ticket.byte_count == 0 || + ticket.byte_count > IB_READER_SOURCE_MAX_BYTES || + strnlen(ticket.sha256_hex, sizeof(ticket.sha256_hex)) != 64U) { + close(descriptor); + return respond(out, 3); + } + for (size_t index = 0; index < 64U; ++index) { + char digit = ticket.sha256_hex[index]; + if (!((digit >= '0' && digit <= '9') || + (digit >= 'a' && digit <= 'f'))) { + close(descriptor); + return respond(out, 3); + } + } /* Never parcel a file-backed Arena descriptor or a writable capability. * A fresh read-only anonymous pipe is the only admitted publication. */ struct stat info; @@ -107,6 +127,10 @@ static binder_status_t transact(AIBinder *binder, transaction_code_t code, return respond(out, 3); } status = respond(out, 0); + if (status == STATUS_OK) + status = AParcel_writeInt32(out, (int32_t)ticket.byte_count); + if (status == STATUS_OK) + status = AParcel_writeString(out, ticket.sha256_hex, 64); if (status == STATUS_OK) status = AParcel_writeParcelFileDescriptor(out, descriptor); close(descriptor); From 34d3d7e8ed91a44ff27d8b4bc6ce901171901d69 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:34:36 -0400 Subject: [PATCH 38/49] Authenticate PFD metadata against browser-held ticket before source read --- native/android/ib_reader_pfd.c | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/native/android/ib_reader_pfd.c b/native/android/ib_reader_pfd.c index 4e71522f..9705bd25 100644 --- a/native/android/ib_reader_pfd.c +++ b/native/android/ib_reader_pfd.c @@ -291,8 +291,22 @@ int ib_reader_source_accept_fd(ib_reader_source *source, #if defined(__ANDROID__) && __ANDROID_API__ >= 29 #include +#include #include "ib_arena_pfd_provider.h" +typedef struct { + char digest[65]; + int32_t allocated_bytes; +} ib_digest_reply; + +static bool digest_reply_allocate(void *opaque, int32_t length, char **buffer) { + ib_digest_reply *reply = opaque; + if (!reply || !buffer || length != 65) return false; + reply->allocated_bytes = length; + *buffer = reply->digest; + return true; +} + /* The private provider owns authorization and obtains the descriptor from a * committed, receipt-verified source. The Binder transaction contains only a * request identity and epoch; the reply contains a PFD, never a path. */ @@ -332,12 +346,26 @@ int ib_reader_source_fetch_binder(ib_reader_source *source, return IB_READER_PFD_BINDER; } int32_t provider_status = -1; + int32_t provider_length = 0; + ib_digest_reply supplied = {{0}, 0}; int descriptor = -1; status = AParcel_readInt32(reply, &provider_status); if (status == STATUS_OK && provider_status == 0) - status = AParcel_readParcelFileDescriptor(reply, &descriptor); + status = AParcel_readInt32(reply, &provider_length); + if (status == STATUS_OK && provider_status == 0) { + status = AParcel_readString(reply, &supplied, digest_reply_allocate); + } + if (status != STATUS_OK || provider_status != 0 || + provider_length <= 0 || + (uint32_t)provider_length != ticket->byte_count || + supplied.allocated_bytes != 65 || + memcmp(supplied.digest, ticket->sha256_hex, 65) != 0) { + AParcel_delete(reply); + return IB_READER_PFD_BINDER; + } + status = AParcel_readParcelFileDescriptor(reply, &descriptor); AParcel_delete(reply); - if (status != STATUS_OK || provider_status != 0 || descriptor < 0) { + if (status != STATUS_OK || descriptor < 0) { if (descriptor >= 0) close(descriptor); return IB_READER_PFD_BINDER; } From 3cf15c6ad69325297edc4a0d6287d14555d469f2 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:34:43 -0400 Subject: [PATCH 39/49] Check new Binder callback returns metadata with source descriptor --- tests/test_android_arena_source_owner.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_android_arena_source_owner.c b/tests/test_android_arena_source_owner.c index 257139f0..6c9acbd5 100644 --- a/tests/test_android_arena_source_owner.c +++ b/tests/test_android_arena_source_owner.c @@ -88,7 +88,8 @@ int main(int argc, char **argv) { accept(&owner, &reader, 1); int fd = -1; - assert(ib_arena_source_owner_open_binder(&owner, request_id, 2, &fd) != 0); + ib_reader_source_ticket binder_ticket = {0}; + assert(ib_arena_source_owner_open_binder(&owner, request_id, 2, &binder_ticket, &fd) != 0); assert(fd == -1); assert(ib_arena_source_owner_open(&owner, "../bad", 1, NULL, &fd) != 0); preserved(&reader, request_id, 1); From 270857571b9a120a4530abc6e279673381288f1b Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 18:34:50 -0400 Subject: [PATCH 40/49] Specify receipt-derived length and SHA in Binder PFD reply --- docs/c67-reader-pfd.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/c67-reader-pfd.md b/docs/c67-reader-pfd.md index d2b943e0..a03753e2 100644 --- a/docs/c67-reader-pfd.md +++ b/docs/c67-reader-pfd.md @@ -29,14 +29,18 @@ Private Binder implementation contract, transaction code **1**: generation. A real provider authorizes Binder calling UID against an explicit same-signer/allowed-reader identity before opening a source. Missing/denied authorization returns a nonzero status and no descriptor. -- Reply parcel: int32 status **0** then a ParcelFileDescriptor with **only** +- Reply parcel: int32 status **0**, source byte_count (int32), 64-character + lowercase source SHA-256, then a ParcelFileDescriptor with **only** the already checked committed source's bytes. Provider closes its write side after the declared byte count. A successful receipt alone does not admit browser navigation. - Browser-held admission ticket: request ID, generation, byte count (1–4194304) and lowercase SHA-256 copied from the previously checked immutable source receipt, **never from untrusted page content**. The - ticket must be authenticated at the broker/reader boundary. For a + ticket must be authenticated at the broker/reader boundary. The client + also rejects any length or SHA difference between its browser-held ticket + and the provider's receipt-derived reply metadata before accepting the FD. + For a matching ticket, the receiver verifies the entire stream, checks for EOF, and compares SHA-256 before replacing the last displayed source. - A newer browser navigation updates the expected generation. Late results, From 2324eb76d8828ab4faf80acbe4c10a64e8b1f73c Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 21:05:36 -0400 Subject: [PATCH 41/49] Bind PFD source-owner admission to complete ICU/Arena v2 receipt and navigation generation --- native/android/ib_arena_source_owner.c | 98 +++++++++++++++++++------- 1 file changed, 73 insertions(+), 25 deletions(-) diff --git a/native/android/ib_arena_source_owner.c b/native/android/ib_arena_source_owner.c index a7534a7a..77b15fe0 100644 --- a/native/android/ib_arena_source_owner.c +++ b/native/android/ib_arena_source_owner.c @@ -19,18 +19,25 @@ #include #include -/* Source-only receipts from PR #152. No parsing or storage authority here: - * the canonical appendfat .used pointer is the authoritative commit boundary. - * The receipt supplies the exact source offset, size and digest. */ +/* ICU #35/IB #152 v2 receipts carry both committed byte provenance and + * explicit HTML admission. No v1 UNOBSERVED receipt can be delivered to + * a visible reader. Source generation must match the requested navigation. */ enum receipt_key { - RK_SCHEMA, RK_REQUEST, RK_REQUESTED_URL, RK_RESOLVED_URL, RK_STATUS, - RK_MIME, RK_TRANSPORT, RK_ARENA, RK_OFFSET, RK_END, RK_LENGTH, - RK_SHA256, RK_REOPEN, RK_NAVIGATION, RK_COUNT + RK_SCHEMA, RK_REQUEST, RK_SOURCE_KEY, RK_SOURCE_GENERATION, + RK_REQUESTED_URL, RK_RESOLVED_URL, RK_STATUS, RK_MIME, RK_CHARSET, + RK_CONTENT_ENCODING, RK_UTF8, RK_REDIRECT_COUNT, + RK_DECLARED_LENGTH, RK_RECEIVED_LENGTH, RK_TRANSPORT, + RK_ARENA, RK_OFFSET, RK_END, RK_LENGTH, RK_SHA256, + RK_REOPEN, RK_ACQUISITION, RK_NAVIGATION, RK_COUNT }; static const char *const receipt_names[RK_COUNT] = { - "schema", "request_id", "requested_url", "resolved_url", "http_status", - "mime_type", "transport", "arena_name", "arena_offset", "arena_end", - "byte_count", "sha256", "reopen_check", "navigation_admitted" + "schema", "request_id", "source_key", "source_generation", + "requested_url", "resolved_url", "http_status", "mime_type", + "charset", "content_encoding", "utf8", "redirect_count", + "declared_length", "received_length", "transport", + "arena_name", "arena_offset", "arena_end", "byte_count", + "sha256", "reopen_check", "acquisition_complete", + "navigation_admitted" }; #define RECEIPT_MAX_BYTES 8192U #define SOURCE_WRITER_DEADLINE_MS 5000 @@ -77,9 +84,22 @@ static int parse_uint(const char *value, uint64_t *out) { return 0; } +static int allowed_url(const char *value) { + if (!value) return 0; + size_t length = strnlen(value, 2049U); + if (length < 9U || length > 2048U) return 0; + if (strncmp(value, "https://", 8) != 0 && + strncmp(value, "http://", 7) != 0) return 0; + for (size_t i = 0; i < length; ++i) { + const unsigned char octet = (unsigned char)value[i]; + if (octet <= 32U || octet >= 127U || octet == '\'') return 0; + } + return 1; +} + static int parse_receipt(char *text, size_t size, const char *request_id, - uint64_t *offset, uint32_t *length, - char sha256[65]) { + uint64_t wanted_generation, uint64_t *offset, + uint32_t *length, char sha256[65]) { char *fields[RK_COUNT] = {0}; size_t found = 0; char *position = text, *end = text + size; @@ -110,31 +130,56 @@ static int parse_receipt(char *text, size_t size, const char *request_id, if (!matched || ++found > RK_COUNT) return -1; position = newline + 1; } + + /* A body-only v1 receipt is not a proof of response identity/charset, + * and cannot authorize reader publication even with a valid byte hash. */ if (found != RK_COUNT || - strcmp(fields[RK_SCHEMA], "ib-icu-arena-v1") != 0 || + strcmp(fields[RK_SCHEMA], "ib-icu-arena-v2") != 0 || strcmp(fields[RK_REQUEST], request_id) != 0 || - strcmp(fields[RK_TRANSPORT], - "icu-app-process-via-binder-shizuku") != 0 || + strcmp(fields[RK_TRANSPORT], "icu-app-process-via-binder-shizuku") != 0 || strcmp(fields[RK_ARENA], "responses.arena") != 0 || strcmp(fields[RK_REOPEN], "MATCH") != 0 || - strcmp(fields[RK_NAVIGATION], "NO") != 0) + strcmp(fields[RK_ACQUISITION], "YES") != 0 || + strcmp(fields[RK_NAVIGATION], "YES") != 0 || + strcmp(fields[RK_STATUS], "200") != 0 || + strcmp(fields[RK_MIME], "text/html") != 0 || + strcmp(fields[RK_CHARSET], "utf-8") != 0 || + strcmp(fields[RK_CONTENT_ENCODING], "identity") != 0 || + strcmp(fields[RK_UTF8], "valid") != 0 || + !allowed_url(fields[RK_REQUESTED_URL]) || + !allowed_url(fields[RK_RESOLVED_URL])) return -1; - /* v1 knows only that ICU emitted body bytes, not that HTML/navigation - * may be displayed as a verified HTTP response. */ - if (strcmp(fields[RK_RESOLVED_URL], "UNOBSERVED") != 0 || - strcmp(fields[RK_STATUS], "UNOBSERVED") != 0 || - strcmp(fields[RK_MIME], "UNOBSERVED") != 0) + char expected_key[IB_READER_REQUEST_ID_CAP + 7U]; + int printed = snprintf(expected_key, sizeof(expected_key), + "arena:%s", request_id); + if (printed < 0 || (size_t)printed >= sizeof(expected_key) || + strcmp(fields[RK_SOURCE_KEY], expected_key) != 0) return -1; - uint64_t begin, finish, amount; + uint64_t generation = 0, redirects = 0; + if (parse_uint(fields[RK_SOURCE_GENERATION], &generation) != 0 || + generation == 0 || generation != wanted_generation || + parse_uint(fields[RK_REDIRECT_COUNT], &redirects) != 0 || + redirects > 8U) + return -1; + + uint64_t begin = 0, finish = 0, amount = 0, received = 0; if (parse_uint(fields[RK_OFFSET], &begin) != 0 || parse_uint(fields[RK_END], &finish) != 0 || parse_uint(fields[RK_LENGTH], &amount) != 0 || + parse_uint(fields[RK_RECEIVED_LENGTH], &received) != 0 || amount == 0 || amount > IB_READER_SOURCE_MAX_BYTES || - begin > finish || finish - begin != amount) + received != amount || begin > finish || + finish - begin != amount) return -1; + if (strcmp(fields[RK_DECLARED_LENGTH], "-1") != 0) { + uint64_t declared = 0; + if (parse_uint(fields[RK_DECLARED_LENGTH], &declared) != 0 || + declared != amount) return -1; + } + const char *hash = fields[RK_SHA256]; if (strlen(hash) != 64U) return -1; for (size_t i = 0; i < 64; ++i) @@ -148,7 +193,8 @@ static int parse_receipt(char *text, size_t size, const char *request_id, } static int read_manifest(int root_fd, const char *request_id, - uint64_t *offset, uint32_t *length, char sha256[65]) { + uint64_t navigation_generation, uint64_t *offset, + uint32_t *length, char sha256[65]) { int dir = -1, manifest = -1, result = -1; char filename[IB_READER_REQUEST_ID_CAP + 5U]; struct stat info; @@ -173,7 +219,8 @@ static int read_manifest(int root_fd, const char *request_id, got += (size_t)amount; } content[size] = '\0'; - result = parse_receipt(content, size, request_id, offset, length, sha256); + result = parse_receipt(content, size, request_id, navigation_generation, + offset, length, sha256); done: if (manifest >= 0) close(manifest); if (dir >= 0) close(dir); @@ -305,7 +352,8 @@ int ib_arena_source_owner_open(ib_arena_source_owner *owner, if (root < 0 || fstat(root, &info) != 0 || !S_ISDIR(info.st_mode) || info.st_uid != geteuid() || (info.st_mode & 077U) != 0) goto done; - if (read_manifest(root, request_id, &offset, &length, expected) != 0) + if (read_manifest(root, request_id, navigation_generation, + &offset, &length, expected) != 0) goto done; bytes = malloc(length); if (!bytes || read_committed_bytes(root, offset, length, bytes) != 0) From 55358053e2f4bd5724dc9e5af024d0549826b0d6 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 21:06:13 -0400 Subject: [PATCH 42/49] Test v2 admitted HTML and stale v1 refusal through canonical and synthetic Arena source --- tests/test_android_arena_source_owner.sh | 58 +++++++++++++++++++----- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/tests/test_android_arena_source_owner.sh b/tests/test_android_arena_source_owner.sh index 5d6303fc..209fb8d0 100644 --- a/tests/test_android_arena_source_owner.sh +++ b/tests/test_android_arena_source_owner.sh @@ -1,5 +1,6 @@ #!/bin/sh -# Source owner test consumes the real canonical appendfat_arena, not a mock. +# Source-owner test consumes the real canonical appendfat_arena, not a mock. +# Admitted v2 HTML source is required before private PFD handoff. set -eu arena_tool=${IB_APPEND_ARENA:-} helper=${IB_TEST_VERIFIED_ARENA_PIPE:-} @@ -14,19 +15,52 @@ work=$(mktemp -d) trap 'rm -rf "$work"' EXIT HUP INT TERM mkdir -m 700 "$work/private" "$arena_tool" create "$work/private/responses.arena" 16777216 > "$work/create.txt" -printf 'IB\000C67\377\n' > "$work/fixture.bin" -test "$(wc -c < "$work/fixture.bin" | tr -d '[:space:]')" -eq 8 -append=$("$arena_tool" append "$work/private/responses.arena" "$work/fixture.bin") +printf '

IB C67

\n' > "$work/fixture.html" +test "$(wc -c < "$work/fixture.html" | tr -d '[:space:]')" -eq 14 +append=$("$arena_tool" append "$work/private/responses.arena" "$work/fixture.html") old=$(printf '%s\n' "$append" | sed -n 's/^APPENDED old_used=\([0-9][0-9]*\) new_used=[0-9][0-9]* capacity_bytes=[0-9][0-9]*$/\1/p') new=$(printf '%s\n' "$append" | sed -n 's/^APPENDED old_used=[0-9][0-9]* new_used=\([0-9][0-9]*\) capacity_bytes=[0-9][0-9]*$/\1/p') -test "$old" = 0 && test "$new" = 8 -"$slice" slice "$work/private/responses.arena" "$old" 8 > "$work/readback" -cmp "$work/fixture.bin" "$work/readback" -digest=$(sha256sum "$work/fixture.bin" | cut -d ' ' -f 1) +test "$old" = 0 && test "$new" = 14 +"$slice" slice "$work/private/responses.arena" "$old" 14 > "$work/readback" +cmp "$work/fixture.html" "$work/readback" +digest=$(sha256sum "$work/fixture.html" | cut -d ' ' -f 1) mkdir -m 700 "$work/private/receipts" + +receipt_v2() { + id=$1 gen=$2 + { + printf 'schema\tib-icu-arena-v2\n' + printf 'request_id\t%s\n' "$id" + printf 'source_key\tarena:%s\n' "$id" + printf 'source_generation\t%s\n' "$gen" + printf 'requested_url\thttps://example.com/start\n' + printf 'resolved_url\thttps://example.com/final\n' + printf 'http_status\t200\n' + printf 'mime_type\ttext/html\n' + printf 'charset\tutf-8\n' + printf 'content_encoding\tidentity\n' + printf 'utf8\tvalid\n' + printf 'redirect_count\t1\n' + printf 'declared_length\t14\n' + printf 'received_length\t14\n' + printf 'transport\ticu-app-process-via-binder-shizuku\n' + printf 'arena_name\tresponses.arena\n' + printf 'arena_offset\t%s\n' "$old" + printf 'arena_end\t%s\n' "$new" + printf 'byte_count\t14\n' + printf 'sha256\t%s\n' "$digest" + printf 'reopen_check\tMATCH\n' + printf 'acquisition_complete\tYES\n' + printf 'navigation_admitted\tYES\n' + } > "$work/private/receipts/$id.tsv" +} +receipt_v2 verified 1 +receipt_v2 verified2 2 + +# A fully hash-matching legacy body is nevertheless not authorized as HTML. { printf 'schema\tib-icu-arena-v1\n' - printf 'request_id\tverified\n' + printf 'request_id\tlegacy\n' printf 'requested_url\thttps://example.com/\n' printf 'resolved_url\tUNOBSERVED\n' printf 'http_status\tUNOBSERVED\n' @@ -35,10 +69,10 @@ mkdir -m 700 "$work/private/receipts" printf 'arena_name\tresponses.arena\n' printf 'arena_offset\t%s\n' "$old" printf 'arena_end\t%s\n' "$new" - printf 'byte_count\t8\n' + printf 'byte_count\t14\n' printf 'sha256\t%s\n' "$digest" printf 'reopen_check\tMATCH\n' printf 'navigation_admitted\tNO\n' -} > "$work/private/receipts/verified.tsv" +} > "$work/private/receipts/legacy.tsv" "$helper" "$work/private" -printf '%s\n' 'checked_arena=actual_appends' 'reader_body_sha256=verified' +printf '%s\n' 'checked_arena=actual_appends' 'pfd_source=v2_admitted_html_only' 'reader_body_sha256=verified' From 74a9777f6aafddebb4df78436bf766e65e75d17e Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 21:06:15 -0400 Subject: [PATCH 43/49] Test v2 admitted HTML and stale v1 refusal through canonical and synthetic Arena source --- ...st_android_arena_source_owner_synthetic.sh | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/tests/test_android_arena_source_owner_synthetic.sh b/tests/test_android_arena_source_owner_synthetic.sh index c6bf15de..9b4ef90e 100644 --- a/tests/test_android_arena_source_owner_synthetic.sh +++ b/tests/test_android_arena_source_owner_synthetic.sh @@ -1,20 +1,51 @@ #!/bin/sh -# Fast host contract test: synthetic pointer+receipt, NOT canonical appendfat. +# Fast host contract test: synthetic pointer + v2 receipts, NOT canonical appendfat. set -eu helper=${IB_TEST_VERIFIED_ARENA_PIPE:-} test -n "$helper" && test -x "$helper" || exit 2 work=$(mktemp -d) trap 'rm -rf "$work"' EXIT HUP INT TERM mkdir -m 700 "$work/private" "$work/private/receipts" -printf 'IB\000C67\377\n' > "$work/fixture.bin" +printf '

IB C67

\n' > "$work/fixture.html" +test "$(wc -c < "$work/fixture.html" | tr -d '[:space:]')" -eq 14 truncate -s 16777216 "$work/private/responses.arena" -dd if="$work/fixture.bin" of="$work/private/responses.arena" conv=notrunc status=none -printf '8\n' > "$work/private/responses.arena.used" +dd if="$work/fixture.html" of="$work/private/responses.arena" conv=notrunc status=none +printf '14\n' > "$work/private/responses.arena.used" : > "$work/private/responses.arena.lock" -digest=$(sha256sum "$work/fixture.bin" | cut -d ' ' -f 1) +digest=$(sha256sum "$work/fixture.html" | cut -d ' ' -f 1) +receipt_v2() { + id=$1 gen=$2 + { + printf 'schema\tib-icu-arena-v2\n' + printf 'request_id\t%s\n' "$id" + printf 'source_key\tarena:%s\n' "$id" + printf 'source_generation\t%s\n' "$gen" + printf 'requested_url\thttps://example.com/start\n' + printf 'resolved_url\thttps://example.com/final\n' + printf 'http_status\t200\n' + printf 'mime_type\ttext/html\n' + printf 'charset\tutf-8\n' + printf 'content_encoding\tidentity\n' + printf 'utf8\tvalid\n' + printf 'redirect_count\t1\n' + printf 'declared_length\t14\n' + printf 'received_length\t14\n' + printf 'transport\ticu-app-process-via-binder-shizuku\n' + printf 'arena_name\tresponses.arena\n' + printf 'arena_offset\t0\n' + printf 'arena_end\t14\n' + printf 'byte_count\t14\n' + printf 'sha256\t%s\n' "$digest" + printf 'reopen_check\tMATCH\n' + printf 'acquisition_complete\tYES\n' + printf 'navigation_admitted\tYES\n' + } > "$work/private/receipts/$id.tsv" +} +receipt_v2 verified 1 +receipt_v2 verified2 2 { printf 'schema\tib-icu-arena-v1\n' - printf 'request_id\tverified\n' + printf 'request_id\tlegacy\n' printf 'requested_url\thttps://example.com/\n' printf 'resolved_url\tUNOBSERVED\n' printf 'http_status\tUNOBSERVED\n' @@ -22,11 +53,11 @@ digest=$(sha256sum "$work/fixture.bin" | cut -d ' ' -f 1) printf 'transport\ticu-app-process-via-binder-shizuku\n' printf 'arena_name\tresponses.arena\n' printf 'arena_offset\t0\n' - printf 'arena_end\t8\n' - printf 'byte_count\t8\n' + printf 'arena_end\t14\n' + printf 'byte_count\t14\n' printf 'sha256\t%s\n' "$digest" printf 'reopen_check\tMATCH\n' printf 'navigation_admitted\tNO\n' -} > "$work/private/receipts/verified.tsv" +} > "$work/private/receipts/legacy.tsv" "$helper" "$work/private" printf 'fixture\tsynthetic-pointer-not-canonical-appendfat\n' From 44e3152635c639648a88f94c362c1d1ccf5546c8 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 21:06:43 -0400 Subject: [PATCH 44/49] Require source-generation match and admitted v2 HTML in PFD source-owner tests --- tests/test_android_arena_source_owner.c | 57 ++++++++++++++++++++----- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/tests/test_android_arena_source_owner.c b/tests/test_android_arena_source_owner.c index 6c9acbd5..210fc92a 100644 --- a/tests/test_android_arena_source_owner.c +++ b/tests/test_android_arena_source_owner.c @@ -12,7 +12,7 @@ #include #include -static unsigned char expected_source[] = {'I','B',0,'C','6','7',255,'\n'}; +static unsigned char expected_source[] = {'<','p','>','I','B',' ','C','6','7','<','/','p','>','\n'}; static const char *request_id = "verified"; static const char *name = "receipts/verified.tsv"; @@ -49,22 +49,22 @@ static void preserved(const ib_reader_source *reader, } static void accept(ib_arena_source_owner *owner, ib_reader_source *reader, - uint64_t generation) { + const char *id, uint64_t generation) { ib_reader_source_ticket ticket = {0}; int fd = -1; - assert(ib_arena_source_owner_open(owner, request_id, generation, + assert(ib_arena_source_owner_open(owner, id, generation, &ticket, &fd) == 0); assert(fd >= 0); assert(ticket.byte_count == sizeof(expected_source)); assert(ticket.navigation_generation == generation); - assert(strcmp(ticket.request_id, request_id) == 0); + assert(strcmp(ticket.request_id, id) == 0); char local_digest[65]; ib_reader_sha256_hex(expected_source, sizeof(expected_source), local_digest); assert(strcmp(ticket.sha256_hex, local_digest) == 0); assert(ib_reader_source_accept_fd(reader, &ticket, fd) == IB_READER_PFD_ACCEPTED); close(fd); - preserved(reader, request_id, generation); + preserved(reader, id, generation); } int main(int argc, char **argv) { @@ -85,9 +85,12 @@ int main(int argc, char **argv) { ib_reader_source_init(&reader); ib_arena_source_owner_want(&owner, 1); ib_reader_source_want(&reader, 1); - accept(&owner, &reader, 1); - + accept(&owner, &reader, request_id, 1); int fd = -1; + /* A legacy v1 receipt has matching bytes but no observed admission. */ + assert(ib_arena_source_owner_open(&owner, "legacy", 1, NULL, &fd) != 0); + assert(fd == -1); + ib_reader_source_ticket binder_ticket = {0}; assert(ib_arena_source_owner_open_binder(&owner, request_id, 2, &binder_ticket, &fd) != 0); assert(fd == -1); @@ -110,6 +113,36 @@ int main(int argc, char **argv) { preserved(&reader, request_id, 1); overwrite(receipt, original, count); + /* Every observed source/admission field participates in PFD authority: + * the byte hash alone is insufficient for an admitted navigation. */ + const char *mutations[][2] = { + {"source_generation\t1", "source_generation\t2"}, + {"source_key\tarena:verified", "source_key\tarena:verifieX"}, + {"http_status\t200", "http_status\t404"}, + {"mime_type\ttext/html", "mime_type\ttext/junk"}, + {"charset\tutf-8", "charset\tutf-7"}, + {"content_encoding\tidentity", "content_encoding\tidentitx"}, + {"utf8\tvalid", "utf8\twrong"}, + {"received_length\t14", "received_length\t13"}, + {"declared_length\t14", "declared_length\t13"}, + {"acquisition_complete\tYES", "acquisition_complete\tNAY"}, + {"navigation_admitted\tYES", "navigation_admitted\tNAY"} + }; + for (size_t i = 0; i < sizeof(mutations) / sizeof(mutations[0]); ++i) { + const char *before = mutations[i][0], *after = mutations[i][1]; + assert(strlen(before) == strlen(after)); + memcpy(damaged, original, count + 1U); + char *location = strstr((char *)damaged, before); + assert(location != NULL); + memcpy(location, after, strlen(after)); + overwrite(receipt, damaged, count); + assert(ib_arena_source_owner_open(&owner, request_id, 1, + NULL, &fd) != 0); + assert(fd == -1); + preserved(&reader, request_id, 1); + } + overwrite(receipt, original, count); + /* Last-good retained when Arena source bytes no longer match receipt. */ int file = open(arena, O_RDWR); assert(file >= 0); @@ -148,12 +181,16 @@ int main(int argc, char **argv) { ib_arena_source_owner_want(&owner, 2); ib_reader_source_want(&reader, 2); - accept(&owner, &reader, 2); - preserved(&reader, request_id, 2); + /* Replaying the same admitted generation as a newer navigation refuses. */ + assert(ib_arena_source_owner_open(&owner, request_id, 2, NULL, &fd) != 0); + assert(fd == -1); + preserved(&reader, request_id, 1); + accept(&owner, &reader, "verified2", 2); + preserved(&reader, "verified2", 2); ib_reader_source_clear(&reader); free(original); free(damaged); - puts("PASS: canonical appendfat receipt + commit pointer, binary PFD, end-to-end double SHA, stale/URL/digest/byte/permission/symlink/corrupt-pointer refusal and recovery"); + puts("PASS: canonical appendfat v2 HTML admission, matching navigation generation, pipe/double SHA, v1/stale/forged-metadata/byte/permission/symlink/corrupt-pointer refusal and recovery"); puts("NOT_RUN: Android private Binder transaction, actual Shizuku/ICU network, Idric reader screen or physical C67"); return 0; } From 5bf806b35bdbbf27992c98bf220cbbf3475d1362 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 21:06:54 -0400 Subject: [PATCH 45/49] Document v2 HTML admission and generation requirements of private PFD source owner --- native/android/ib_arena_source_owner.h | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/native/android/ib_arena_source_owner.h b/native/android/ib_arena_source_owner.h index 26e93159..7fe88cc6 100644 --- a/native/android/ib_arena_source_owner.h +++ b/native/android/ib_arena_source_owner.h @@ -20,11 +20,14 @@ int ib_arena_source_owner_init(ib_arena_source_owner *owner, void ib_arena_source_owner_want(ib_arena_source_owner *owner, uint64_t navigation_generation); -/* All-or-nothing source-open. The already committed canonical appendfat - * range and private receipt are re-read and SHA-256 checked before returning - * an independent, read-only anonymous pipe. The returned receipt-derived - * ticket is the sole expected metadata for the reader; navigation itself is - * NOT admitted by this byte-transfer function. Caller owns *read_fd. */ +/* All-or-nothing admitted-source open. Requires IB ICU/Arena v2 with + * observed HTTP 200, HTML/UTF-8, completed acquisition, positive admission + * and a receipt source_generation matching navigation_generation. Legacy + * body-only v1 receipts never reach the reader. Independently reopen the + * canonical committed byte range and check SHA-256 before returning a + * read-only anonymous pipe. The browser still controls navigation ordering + * and the reader checks the ticket, stream and digest again. + * Caller owns *read_fd. */ int ib_arena_source_owner_open(ib_arena_source_owner *owner, const char *request_id, uint64_t navigation_generation, From 4e7b839d325af344b1c79bbd76f6611ef8cc0b7d Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 21:10:52 -0400 Subject: [PATCH 46/49] Document generation-bound admitted v2 PFD source and refuse v1 on native path --- docs/c67-reader-pfd.md | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/docs/c67-reader-pfd.md b/docs/c67-reader-pfd.md index a03753e2..44b897f0 100644 --- a/docs/c67-reader-pfd.md +++ b/docs/c67-reader-pfd.md @@ -10,7 +10,8 @@ Idriç screen connection, or physical C67 Binder execution is claimed.** Idriç browser-owned navigation epoch + source identity → Shizuku-authorized ICU worker (no Android-reader HTTP) → exact captured bytes → checked appendfat Arena source - → source-owner verifies immutable receipt + reopened bytes + → source-owner checks admitted v2 receipt + matching navigation epoch + → independently reopens and hashes committed bytes → private app-scoped Binder provider: OPEN_COMMITTED_SOURCE(request,epoch) → one read-only, per-open ParcelFileDescriptor (not Arena file FD) → native reader verifies exact stream length + SHA-256 + epoch @@ -32,8 +33,8 @@ Private Binder implementation contract, transaction code **1**: - Reply parcel: int32 status **0**, source byte_count (int32), 64-character lowercase source SHA-256, then a ParcelFileDescriptor with **only** the already checked committed source's bytes. Provider closes its write - side after the declared byte count. A successful receipt alone does not - admit browser navigation. + side after the declared byte count. A matching, admitted v2 receipt is necessary but does not + replace the browser's navigation authorization. - Browser-held admission ticket: request ID, generation, byte count (1–4194304) and lowercase SHA-256 copied from the previously checked immutable source receipt, **never from untrusted page content**. The @@ -86,8 +87,8 @@ host stand-in for descriptor ownership, not a claim of tested Android Binder delivery or tested cross-UID Android authority.** The second host test constructs a real pinned sd-card-append-fat Arena, -appends binary bytes using its canonical append command, publishes the -existing IB v1 receipt, then drives the actual C source owner and reader +appends an admitted UTF-8 HTML fixture using its canonical append command, +publishes a complete v2 receipt and a legacy v1 refusal fixture, then drives the actual C source owner and reader verifiers end-to-end. It mutates receipts, source bytes, commit pointer, generation, root permissions and path identity, requiring refusal and last-good preservation. This is still Linux-hosted source admission. @@ -108,9 +109,10 @@ or that an Android Binder transaction has happened. 2. Bind the native C receiver to the **existing checked Idriç Material reader** (#129/#147), with qualified DEX/native FFI and lifecycle. Do not introduce Java/Kotlin application logic or a replacement UI. -3. Admit a page only when ICU's resolved URL, status, encoding and - redirect evidence are available; retain the source and link events - through Idriç. At present PR #152 says navigation_admitted=NO. +3. The native source owner now requires v2 admission, observed HTTP status, + resolved URL, MIME, charset and a source-generation/epoch match. + IB #152 host tests passed, but real C67 ICU/TLS remains NOT_RUN. + Navigation ordering and link events remain owned by Idriç. 4. Device-check denied/missing Shizuku, private reader UID, real ICU HTTPS, committed Arena and readback digest, actual Binder PFD transaction, page presentation, copying text, replacement/restart @@ -120,3 +122,18 @@ or that an Android Binder transaction has happened. Existing source owners: IB #151, #142, #141, #97; IB PR #112 (Binder-first host), #129/#147 (checked Android reader), #152 (ICU/Arena). ICU PR #35 owns real resolved-URL/redirect metadata. + +## ICU #35 / IB #152 v2 admission invariant + +Native PFD publication requires exactly 23 receipt fields, schema +`ib-icu-arena-v2`, `source_key=arena:REQUEST_ID`, source_generation +matching the requested epoch, observed HTTP 200, `text/html`, explicit +`utf-8` charset, identity content encoding, ICU UTF-8 validity, +`acquisition_complete=YES`, `reopen_check=MATCH` and +`navigation_admitted=YES`. Reopened canonical Arena body length and SHA-256 +must match. v1 UNOBSERVED receipts are always refused by this native source +owner, including when their body digests are correct. + +These changes are host candidates until exact-head CI and independent AI-CI +acceptance. The host fixture is not live ICU on C67; physical Shizuku, +cross-UID worker ingress, Binder/Idriç DEX registration and UI remain NOT_RUN. From f98ad77d66fc9948978a5b0c9030e1aee7ed24a9 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 22:27:09 -0400 Subject: [PATCH 47/49] Require independent AI-CI v2 Arena verdict before PFD host source tests --- tests/test_android_arena_source_owner.sh | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test_android_arena_source_owner.sh b/tests/test_android_arena_source_owner.sh index 209fb8d0..747da5f3 100644 --- a/tests/test_android_arena_source_owner.sh +++ b/tests/test_android_arena_source_owner.sh @@ -24,7 +24,7 @@ test "$old" = 0 && test "$new" = 14 "$slice" slice "$work/private/responses.arena" "$old" 14 > "$work/readback" cmp "$work/fixture.html" "$work/readback" digest=$(sha256sum "$work/fixture.html" | cut -d ' ' -f 1) -mkdir -m 700 "$work/private/receipts" +mkdir -m 700 "$work/private/receipts" "$work/private/staging" receipt_v2() { id=$1 gen=$2 @@ -74,5 +74,19 @@ receipt_v2 verified2 2 printf 'reopen_check\tMATCH\n' printf 'navigation_admitted\tNO\n' } > "$work/private/receipts/legacy.tsv" +# A separate AI-CI consumer independently checks this fixture's observed +# admission and committed bytes before the native PFD source owner runs. +aici=${IB_AICI_SOURCE_VERIFY:-} +test -n "$aici" && test -f "$aici" || { + printf 'source admission requires pinned independent AI-CI verifier\n' >&2 + exit 1 +} +sh "$aici" "$work/private" verified 1 https://example.com/start +sh "$aici" "$work/private" verified2 2 https://example.com/start +if sh "$aici" "$work/private" legacy 1 https://example.com/ > "$work/legacy.aici" 2>/dev/null; then + printf 'independent verifier admitted a v1 receipt\n' >&2 + exit 1 +fi +test ! -s "$work/legacy.aici" "$helper" "$work/private" printf '%s\n' 'checked_arena=actual_appends' 'pfd_source=v2_admitted_html_only' 'reader_body_sha256=verified' From e8116f30296d261015ffff01c84a0a4264c98374 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 22:27:11 -0400 Subject: [PATCH 48/49] Require independent AI-CI v2 Arena verdict before PFD host source tests --- .../test_android_arena_source_owner_synthetic.sh | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test_android_arena_source_owner_synthetic.sh b/tests/test_android_arena_source_owner_synthetic.sh index 9b4ef90e..d199d8cb 100644 --- a/tests/test_android_arena_source_owner_synthetic.sh +++ b/tests/test_android_arena_source_owner_synthetic.sh @@ -5,7 +5,7 @@ helper=${IB_TEST_VERIFIED_ARENA_PIPE:-} test -n "$helper" && test -x "$helper" || exit 2 work=$(mktemp -d) trap 'rm -rf "$work"' EXIT HUP INT TERM -mkdir -m 700 "$work/private" "$work/private/receipts" +mkdir -m 700 "$work/private" "$work/private/receipts" "$work/private/staging" printf '

IB C67

\n' > "$work/fixture.html" test "$(wc -c < "$work/fixture.html" | tr -d '[:space:]')" -eq 14 truncate -s 16777216 "$work/private/responses.arena" @@ -59,5 +59,19 @@ receipt_v2 verified2 2 printf 'reopen_check\tMATCH\n' printf 'navigation_admitted\tNO\n' } > "$work/private/receipts/legacy.tsv" +# A separate AI-CI consumer independently checks this fixture's observed +# admission and committed bytes before the native PFD source owner runs. +aici=${IB_AICI_SOURCE_VERIFY:-} +test -n "$aici" && test -f "$aici" || { + printf 'source admission requires pinned independent AI-CI verifier\n' >&2 + exit 1 +} +sh "$aici" "$work/private" verified 1 https://example.com/start +sh "$aici" "$work/private" verified2 2 https://example.com/start +if sh "$aici" "$work/private" legacy 1 https://example.com/ > "$work/legacy.aici" 2>/dev/null; then + printf 'independent verifier admitted a v1 receipt\n' >&2 + exit 1 +fi +test ! -s "$work/legacy.aici" "$helper" "$work/private" printf 'fixture\tsynthetic-pointer-not-canonical-appendfat\n' From d9f18955801864cce2e8c8ac02df853772b271ac Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 22:27:29 -0400 Subject: [PATCH 49/49] Run separately pinned AI-CI admitted-source verifier in both PFD host CI jobs --- .github/workflows/ib-c67-reader-pfd.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/ib-c67-reader-pfd.yml b/.github/workflows/ib-c67-reader-pfd.yml index 37eaec87..89a7c690 100644 --- a/.github/workflows/ib-c67-reader-pfd.yml +++ b/.github/workflows/ib-c67-reader-pfd.yml @@ -27,9 +27,20 @@ jobs: steps: - name: Checkout candidate source uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Check out pinned independent AI-CI admitted-source verifier + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: isomorphisms/ai-ci + ref: 634626a14834e14b776616440e0a9071708f81b7 + path: .deps/ai-ci + persist-credentials: false - name: Compile native receiver and fake-boundary source owner run: | set -eu + test "$(git -C .deps/ai-ci rev-parse HEAD)" = 634626a14834e14b776616440e0a9071708f81b7 + sh .deps/ai-ci/ib-source/self-test.sh > build-source-self-test.txt + grep -F 'PASS: independent IB v2 oracle' build-source-self-test.txt + export IB_AICI_SOURCE_VERIFY="$GITHUB_WORKSPACE/.deps/ai-ci/ib-source/verify.sh" mkdir -p build/c67-pfd/fast cc -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror -pthread \ native/android/ib_reader_pfd.c native/android/ib_arena_source_owner.c \ @@ -46,6 +57,13 @@ jobs: steps: - name: Checkout pinned PR source uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Check out pinned independent AI-CI admitted-source verifier + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: isomorphisms/ai-ci + ref: 634626a14834e14b776616440e0a9071708f81b7 + path: .deps/ai-ci + persist-credentials: false - name: Checkout immutable canonical appendfat Arena uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: @@ -75,6 +93,8 @@ jobs: run: | set -eu test -x "$ICK" + test "$(git -C .deps/ai-ci rev-parse HEAD)" = 634626a14834e14b776616440e0a9071708f81b7 + export IB_AICI_SOURCE_VERIFY="$GITHUB_WORKSPACE/.deps/ai-ci/ib-source/verify.sh" "$ICK" -fno-link-libatomic -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \ .deps/appendfat/lib/appendfat_arena.c \ .deps/appendfat/tools/appendfat_arena.c -o build/c67-pfd/host/appendfat_arena