Peios Learn
Products
PePeios pkpekit PvProvium UDUniversal Directory TrTrail PrProject WiWispist
Using Peios Security Basics Technical Documentation Source
Using Peios Security Basics Technical Documentation Source
Peios

Peios System Protocols Kernel

The contracts spoken across the kernel boundary — the protocols a Peios kernel subsystem depends on a userspace process to fulfil, and the formats it exchanges with one.

Single-page view · as markdown

1.1 Scope

Peios / Advanced Peios / PSPK / Introduction

This document defines the Peios System Protocols Kernel (PSPK): the protocols spoken across the kernel boundary, between a kernel subsystem and a userspace process that serves it.

A contract belongs in this document when both of these hold:

  • one party is a kernel subsystem and the other is a userspace process; and
  • the userspace side is a public, implementable role — a third party can write a program that fills it.

The two parties need not be in conversation. A live protocol has a kernel subsystem and a process exchanging messages; a format has one side producing an artifact that the other consumes, perhaps long afterwards and on a different machine. Both are specified here, because both are contracts a third party has to satisfy exactly.

The second condition is what separates a PSPK protocol from a system-call surface. A system call is an interface the kernel offers to any program that asks. A PSPK protocol is a contract the kernel depends on some program to fulfil: the kernel is the party asking, and the userspace process is authoritative for the answer.

For each protocol, this document covers:

  • the channel, and how a userspace party attaches to it and is recognised
  • message or artifact framing, encoding, and the rules under which the format may be extended
  • the requests the kernel issues, the responses it expects, and their ordering
  • what the kernel-side party validates for itself rather than believing from a response
  • behaviour on failure, refusal, and disconnection
  • the conformance requirements for the userspace role

This document does not cover:

  • The behaviour and data model of the kernel subsystem itself — defined in that subsystem's specification
  • System-call and ioctl surfaces — defined in that subsystem's specification
  • The binary structures these protocols carry — defined in PCDS
  • Standards a system MUST implement to be Peios — defined in PGSS
  • Protocols between userspace components — defined in PSPU
  • How a userspace implementation stores its data or computes its answers — its own design

1.1.1 Trust across the boundary #

A PSPK protocol crosses a trust boundary in the direction that matters most: a kernel subsystem is asking a lower-privileged process for something it will then act on. Every specification in this document therefore states explicitly which parts of a response the kernel establishes for itself and which it takes on the userspace party's word.

1.1.2 Relationship to PGSS #

A protocol in this document is not a conformance requirement in the sense PGSS defines. It is the interface a particular Peios kernel subsystem uses to reach the processes that serve it; a system built from different kernel subsystems is still Peios. These protocols are specified because they are public even so — a third party writing an implementation of the userspace role needs the contract written down.

1.2 Conventions

Peios / Advanced Peios / PSPK / Introduction

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY in this document are to be interpreted as described in RFC 2119. Text set off as a note is informative, not normative.

Everything else — roles, byte order, sizes, layout tables, notation, strings, timestamps, citation, and the external standards this anthology depends on — is defined in the Conventions book and is not restated here. PSPK departs from none of it.

Where a chapter needs a convention of its own, that chapter states it.

2.1 Scope and Roles

Peios / Advanced Peios / PSPK / KMES Event Stream

This chapter specifies the contract between the Kernel Mediated Event Subsystem and the userspace processes that consume the events it produces.

Two roles participate.

The producer is KMES, a subsystem of the Peios kernel. It constructs events, stamps them with metadata that a consumer cannot forge, places them in per-CPU shared memory ring buffers, and notifies sleeping consumers. There is one producer.

The consumer is a userspace process that maps one or more ring buffers and reads events from them. The consumer role is publicly implementable: any process holding the required privilege MAY attach, and more than one consumer MAY attach to the same buffer at the same time. A conforming consumer is the subject of the requirements in this chapter.

This chapter covers:

  • the binary layout of an event, which a consumer MUST parse
  • the layout of a mapped ring buffer and the meaning of each metadata field
  • how a consumer attaches, maps, and discovers the buffer set
  • the protocol a consumer follows to drain events, to detect and account for loss, and to sleep and be woken
  • the protocol a consumer follows when the producer replaces a buffer
  • the memory ordering both roles rely on

This chapter does not cover:

  • how KMES constructs, stamps, buffers, or overwrites events — the producer's internals are described in the Peios Kernel TRM
  • the emission interfaces, by which a process or kernel subsystem produces an event rather than consuming one
  • event type vocabulary, payload schemas, persistence, indexing, or querying — these are the concern of the event storage service
  • the encoding of payload bytes beyond their being a single MessagePack value

2.1.1 Producing versus consuming #

Emission is not part of this contract. A process emits events by calling the KMES emission system calls, which are an ordinary kernel interface offered to any caller that holds the privilege — the kernel computes the result and the caller reads it. Consumption is different: the kernel deposits bytes in shared memory and depends on an independently written program to interpret them correctly, to sequence its reads against concurrent writes, and to notice when it has fallen behind. That program's obligations have to be written down, which is why they are here.

2.1.2 What the kernel establishes for itself #

A consumer maps one page that it can write: the consumer metadata page. Everything the kernel reads from that page is advisory.

KMES reads exactly one field from consumer-writable memory, the need_wake flag, and treats any nonzero value as set. The flag can only cause KMES to perform a wake that was not needed or to skip one that was; it cannot affect the contents of the data region, the producer metadata, the sequence numbering, or another consumer's view of any of these. A consumer that corrupts the page — deliberately or otherwise — degrades notification for consumers sharing that buffer and nothing else.

Consumers MUST NOT rely on KMES validating anything else they write, because KMES reads nothing else.

The reverse direction is stronger. The producer metadata page and the data region are mapped read-only, and no privilege, capability, or token grants a consumer write access to them. Every identity stamp in an event header is captured by the kernel from kernel state at the moment of the write; an emitting process cannot set, influence, or suppress it. A consumer MAY therefore treat the identity fields of a delivered event as authoritative.

2.1.3 Privilege and the trust model #

Attaching requires SeSecurityPrivilege, which is a very high-trust privilege. Direct ring buffer access is not the ordinary way to consume events: it grants an unfiltered view of every event on the system, with no per-event access control. Ordinary consumers obtain events from the event storage service, which enforces per-event access control on top of this interface.

Because the consumer metadata page is shared by every consumer attached to a buffer, a consumer holding SeSecurityPrivilege can suppress notification for the others attached to that buffer. This is accepted rather than defended against: the privilege required to attach at all is higher than the privilege this would subvert.

2.2 Event Format

Peios / Advanced Peios / PSPK / KMES Event Stream

An event is an indivisible record: a packed binary header followed immediately by a payload. Header and payload are always stored, delivered, and consumed as one contiguous byte sequence, and neither is meaningful alone.

2.2.1 Header layout #

The header fields are laid out sequentially with no padding and no alignment gaps. All multi-byte integers are little-endian.

OffsetSizeTypeFieldDescription
04u32event_sizeTotal size of the event, header plus payload, in bytes.
44u32header_sizeSize of the header in bytes.
88u64timestampWall clock time at emission, in nanoseconds since the Unix epoch.
168u64sequencePer-CPU, per-boot monotonic sequence number.
242u16cpu_idThe CPU on which the event was emitted, identifying the ring buffer that carries it.
261u8origin_classThe emission path that produced the event.
2716GUIDeffective_token_guidGUID of the effective token of the emitting thread. Null GUID if unavailable.
4316GUIDtrue_token_guidGUID of the emitting process's primary token. Null GUID if unavailable.
5916GUIDprocess_guidGUID of the emitting process. Null GUID if unavailable.
752u16type_lenLength of the event type string in bytes.
77type_len[u8]typeEvent type string, UTF-8, not null-terminated.

GUIDs use the binary format defined in PCDS and are opaque 16 bytes to this contract. The null GUID is sixteen zero bytes and means the field is not applicable or was not available.

header_size is 77 + type_len in this version of the format. A consumer MUST use header_size to locate the payload and MUST NOT compute the payload offset from 77 + type_len or from any other constant, so that a future header extension does not break it. All fields before type are at fixed offsets and will remain so.

The payload occupies the bytes from header_size to event_size. The next event in a ring buffer begins event_size bytes after the start of the current one, with no alignment padding between events.

2.2.2 Payload #

The payload is exactly one MessagePack value, and its structure is defined by the emitter. KMES does not interpret it.

A consumer MUST NOT assume a payload is present: an event emitted by a kernel subsystem MAY have event_size == header_size, meaning a header and no payload at all. Events emitted through the system calls always carry a payload, because an empty byte sequence is not a valid MessagePack value and is rejected at the syscall boundary.

Note that MessagePack encodes its own length prefixes big-endian, whereas every integer in the event header is little-endian. Both appear in one event.

2.2.3 Event types #

The event type is an arbitrary UTF-8 string. KMES imposes no structure, namespace, or naming convention on it, and applies no case folding or normalisation. Consumers MUST compare event types as raw byte sequences.

2.2.4 Origin class #

ValueOrigin
0Userspace, via system call
1KMES
2KACS
3LCS

Values 4–255 are unassigned. A consumer MUST tolerate an unrecognised origin class rather than rejecting the event, so that a kernel subsystem added later does not break it.

Events with origin class 0 were emitted through the system call interface, and their origin class is set by the kernel, not by the caller. A userspace emitter cannot claim to be a kernel subsystem.

2.2.5 Identity fields #

The three identity GUIDs are captured by the kernel at the moment the event is written to the ring buffer, not when the emitting call began.

  • effective_token_guid is the token governing the emitting thread's access rights. If the thread was impersonating, this is the impersonation token; otherwise it equals true_token_guid.
  • true_token_guid is the emitting process's primary token, regardless of impersonation.
  • process_guid identifies the emitting process. It is assigned when the process is created and does not change across exec.

Any of the three MAY be the null GUID, meaning the kernel had no identity to record — emission before the access control subsystem initialised, or from a context with no associated process such as a kernel worker thread. A consumer MUST treat a null identity as "unattributed" and MUST NOT treat it as a valid GUID value that could match a real token or process.

2.2.6 Ordering #

Events from different CPUs are ordered by timestamp. Events with identical timestamps from different CPUs were genuinely concurrent and have no defined relative order.

Within a single CPU, sequence is the ordering primitive. It is monotonic across wall clock discontinuities, which timestamp is not: a clock adjustment can move timestamps backwards, and a consumer that requires monotonic ordering within a CPU MUST use sequence. Events with identical timestamps on the same CPU are ordered by sequence.

Each CPU numbers independently and there is no global sequence. The counter starts at zero when the kernel module loads and is incremented before a value is taken, so the first event on a CPU carries sequence number 1 and sequence 0 is never assigned. The pair (cpu_id, sequence) uniquely identifies an event within one boot.

A gap in the sequence for a given CPU means events were lost — either overwritten before the consumer read them, or dropped by the kernel before they reached the buffer. Sequence numbers are continuous across a buffer replacement, so a generation change does not itself produce a gap.

2.3 Attaching and Mapping

Peios / Advanced Peios / PSPK / KMES Event Stream

2.3.1 Attaching #

A consumer attaches to one per-CPU ring buffer at a time by calling kmes_attach with a logical CPU index and a pointer to a u64 that receives the buffer's capacity. The call returns a file descriptor.

The caller MUST hold SeSecurityPrivilege, enabled; the call fails with EPERM otherwise.

The CPU index uses the same numbering as the cpu_id field in the ring buffer metadata and in event headers. Indexes run from 0 to one below the slot count, and the set of buffers is fixed when KMES initialises and does not change while the system runs.

The slot count is not the number of buffers. Slots are indexed by logical CPU id, so a slot within the range holds no buffer when that CPU is not possible, and the two quantities differ on any system whose possible-CPU mask is sparse. An index at or beyond the slot count fails with EINVAL, and so does an index inside it whose slot holds no buffer; the two are not distinguishable from the return value.

A consumer discovers the slot count by calling kmes_attach with the CPU index KMES_ATTACH_QUERY_SLOTS. The call writes the slot count through the capacity pointer, returns 0, and opens no descriptor. It is gated on SeSecurityPrivilege exactly as an attach is.

A consumer MUST enumerate by walking every index from 0 to one below the slot count, and MUST treat EINVAL as "this slot holds no buffer" and continue. A consumer MUST NOT treat the first EINVAL as the end of the set: doing so silently abandons every buffer above the first hole, whose events then accumulate and are overwritten with no consumer able to reach them.

A consumer SHOULD attach to every buffer in the set: a buffer with no consumer still receives events, and those events are lost when it wraps.

A consumer MAY call kmes_attach more than once for the same CPU and receives a distinct file descriptor each time. All descriptors for one CPU refer to the same buffer, and therefore to the same producer metadata, consumer metadata, and data region. Multiple consumers MAY attach to one buffer concurrently. Each maintains its own read position, in its own memory; the kernel does not track consumer read positions, does not know how many consumers exist, and does not know how far behind any of them is. The consumer metadata page is shared per buffer and is not a per-consumer read-position store.

The descriptor supports exactly two operations: mmap() and close(). Closing it invalidates the mapping.

2.3.2 Mapping #

The consumer maps the whole region in a single call:

mmap(NULL, 8192 + 2 * capacity, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)

The mapping request MUST use MAP_SHARED, MUST pass an offset of zero, and MUST pass a length of exactly 8192 + 2 * capacity, where capacity is the value kmes_attach returned. Any other combination fails with EINVAL. The consumer does not map the regions separately.

The mapped region has three parts:

OffsetSizeRegionConsumer access
04096Producer metadata pageRead-only
40964096Consumer metadata pageRead-write
81922 × capacityData regionRead-only

Per-page permissions are enforced by the kernel regardless of the PROT flags requested: the producer metadata page and the data region are mapped read-only whatever the consumer asks for, and no privilege raises that. The consumer metadata page is writable by ordinary stores.

capacity is always a power of two, so a position's offset within the data region is position & (capacity - 1).

2.3.3 The double mapping #

The data region occupies 2 × capacity bytes of address space backed by capacity bytes of memory: the same pages appear twice, consecutively. An event that crosses the end of the buffer is therefore readable as one contiguous byte sequence starting at its wrapped offset, and a consumer MUST NOT implement wrap handling of its own. Reading an event at offset position & (capacity - 1) is always correct, even when the event extends past capacity.

A consumer MUST NOT read past event_size bytes from the start of an event. The data region is not scrubbed when events are overwritten, so the bytes beyond an event are unrelated remnants of older events.

2.3.4 Producer metadata page #

The producer metadata page is written by KMES and read by consumers. Fields are separated onto 64-byte cache lines by update frequency, so that the position fields KMES writes on every event do not invalidate the line holding the fields it never writes.

2.3.4.1 Bytes 0–63: identification #

OffsetSizeTypeFieldDescription
08[u8; 8]magic4B 4D 45 53 52 49 4E 47, KMESRING in ASCII. Compared byte by byte, not as an integer.
84u32versionRing buffer format version. This version is 1.
122u16cpu_idThe CPU this buffer belongs to.
142u16reserved0Reserved, zero.
168u64capacityData region capacity in bytes. A power of two.
248u64data_offsetOffset from the start of the mapping to the data region. 8192.
328u64generationBuffer generation. Starts at 1 for the first buffer on each CPU and increases by one each time the buffer is replaced.
4024--reserved1Reserved, zero.

A consumer MUST verify magic and version before trusting any other field in the mapping.

A consumer MAY cache magic, version, cpu_id, capacity, and data_offset for the lifetime of the mapping; these do not change once the buffer exists. A consumer MUST NOT cache generation: it shares this cache line but is written when the buffer is superseded, and re-reading it is how a consumer learns that it must re-attach.

2.3.4.2 Bytes 64–127: positions #

OffsetSizeTypeFieldDescription
648u64write_posMonotonically increasing byte offset at which the next event will be written. Never wraps.
728u64tail_posByte offset of the oldest surviving event. Advanced by KMES as events are overwritten.
8048--reserved2Reserved, zero.

Both are absolute byte offsets that increase without bound; the corresponding data region offset is the value masked with capacity - 1. A u64 byte offset does not overflow in any practical deployment — at a sustained gigabyte per second it would take over five hundred years — and consumers MUST NOT implement wrap handling for these counters.

2.3.4.3 Bytes 128–191: notification #

OffsetSizeTypeFieldDescription
1284u32futex_counterIncremented by KMES when it wakes sleeping consumers.
13260--reserved3Reserved, zero.

The counter is 32-bit because the Linux futex operates on 32-bit integers, and it is incremented only when need_wake is set.

2.3.5 Consumer metadata page #

OffsetSizeTypeFieldDescription
40961u8need_wakeSet by a consumer that is about to sleep. Read by KMES after writing an event; any nonzero value counts as set.
40974095--reserved4Reserved.

This page is shared by every consumer attached to the buffer. A consumer MUST NOT store per-consumer state on it, and in particular MUST NOT store its read position there.

Consumers MUST NOT write to any offset in this page other than need_wake. Reserved bytes are reserved for future extension of this contract.

2.4 Consumer Protocol

Peios / Advanced Peios / PSPK / KMES Event Stream

A consumer typically dedicates one thread to each buffer. Each thread independently drains its buffer, sleeps when the buffer is empty, and re-attaches when the buffer is replaced. The protocol uses no locks and, while events are available, no system calls.

Each consumer keeps its own read_pos in its own memory. On first attaching to a buffer, a consumer SHOULD set read_pos to the buffer's current tail_pos, which starts it at the oldest surviving event.

2.4.1 Draining #

  1. Load write_pos with acquire ordering. If write_pos == read_pos, no events are available: go to the notification wait.
  2. Load tail_pos with acquire ordering. If read_pos < tail_pos, the events at the read position have been overwritten and the consumer has been lapped: set read_pos = tail_pos. The skipped span is lost, and will show up as a sequence gap.
  3. Save the current tail_pos as saved_tail.
  4. Read the event at data region offset read_pos & (capacity - 1).
  5. Re-read tail_pos. If it has advanced past saved_tail and read_pos < tail_pos, the event was overwritten while it was being read. The bytes just read MUST be discarded: go to step 2.
  6. Check that event_size > 0 and event_size >= header_size. If either fails, the bytes are not a valid event; the consumer SHOULD set read_pos = tail_pos and go to step 2. A consumer MUST perform this check: an event_size of zero would otherwise make the drain loop spin forever.
  7. Process the event. Advance read_pos by the event's event_size. Go to step 1.

A consumer MUST NOT read beyond an event's event_size boundary.

Steps 3 and 5 are what make a lock-free read safe against a producer that is overwriting the region being read. A consumer that omits the re-read can process a torn event assembled from two different events' bytes.

2.4.2 Detecting loss #

A consumer SHOULD track the last sequence number it processed for each CPU. A gap means events were lost, whether because they were overwritten before being read or because the kernel dropped them before they reached the buffer. The size of the gap is the number of events lost.

Loss is a normal condition under load, not an error: the buffer preserves recent events at the cost of old ones. A consumer SHOULD report loss rather than treating it as fatal.

2.4.3 Notification wait #

When a buffer is empty:

  1. Store 1 to need_wake with release ordering.
  2. Re-load write_pos with acquire ordering. If events arrived between the drain loop finding the buffer empty and this store, clear need_wake to 0 and return to the drain loop. This re-check is REQUIRED: without it, an event written in that window would find need_wake still clear, and the consumer would sleep with events waiting.
  3. Read futex_counter.
  4. Optionally spin, re-checking write_pos. If events arrive during the spin, clear need_wake to 0 and return to the drain loop. The spin duration is the consumer's choice, and a consumer MAY omit this step entirely.
  5. Call futex_wait(&futex_counter, last_seen_value), where last_seen_value is the value read in step 3. The kernel puts the thread to sleep only if futex_counter still holds that value, so a wake that arrived in the meantime is not missed.
  6. On waking, clear need_wake to 0 and return to the drain loop.

The futex address is the futex_counter field in the mapped producer metadata page. This is a shared futex, keyed by the page's backing inode, and a consumer MUST wait on it as such: a wait issued with FUTEX_PRIVATE_FLAG will never be woken.

Clearing need_wake to 0 in steps 2, 4, and 6 MAY be a relaxed store. If KMES reads a stale set value after the consumer has cleared it, it performs a wake on a thread that is already awake, which is harmless.

Under sustained load a consumer never reaches the notification wait: it stays in the drain loop, need_wake stays 0, and the producer's notification cost is a single byte read per event.

2.4.4 Buffer replacement #

KMES replaces every buffer when its configured capacity changes. Replacement preserves as many surviving events as the new capacity allows and keeps sequence numbering continuous, but it invalidates positions: the events are re-compacted from position 0 in the new buffer, so the consumer's read_pos means nothing there.

After each drain cycle — the buffer emptied, or a batch limit reached — a consumer SHOULD read generation. If it differs from the value last seen:

  1. Record the sequence number of the last event successfully processed from this buffer.
  2. Finish draining the old buffer up to its write_pos, which is now frozen: KMES has stopped writing to it. A consumer MUST complete this drain before switching, or it loses every event written between its read position and the switchover.
  3. Call kmes_attach for the same CPU to obtain a descriptor for the replacement buffer, and map it.
  4. Read the new buffer's capacity, write_pos, and tail_pos.
  5. Scan the new buffer for the first event whose sequence number is greater than the recorded one, and set read_pos to that event's position. A consumer MUST locate its position by sequence number and MUST NOT carry read_pos across.
  6. Close the old descriptor and unmap the old buffer.
  7. Resume draining from the new buffer.

The old buffer's pages remain valid for as long as any consumer keeps them mapped, so a consumer is never racing to finish before the memory disappears.

If the new capacity is large enough to hold everything that survived in the old buffer, no events are lost across the replacement. If it is smaller, the oldest surviving events are discarded until the remainder fits — the same overwrite semantics applied against the smaller capacity — so loss is bounded to the oldest part of the buffer and appears as a sequence gap.

A consumer sleeping on the old buffer is woken when the replacement happens, provided its need_wake was set, so it observes the generation change rather than sleeping indefinitely on a buffer that will never receive another event.

2.4.5 Memory ordering #

OperationOrderingPurpose
Producer stores tail_posreleaseThe advanced tail is visible before the data that replaces the events it skipped past.
Producer stores write_posreleaseComplete event data is visible before the position that makes it reachable.
Producer stores futex_counterreleaseA consumer waking from the futex observes all prior writes.
Consumer stores need_wake = 1releaseThe producer observes the flag before the consumer waits.
Consumer stores need_wake = 0relaxedA stale read causes only a spurious wake.
Consumer loads write_pos in the drain loopacquirePairs with the producer's release.
Consumer loads write_pos after setting need_wakeacquireCloses the window between finding the buffer empty and announcing the sleep.
Consumer loads tail_posacquirePairs with the producer's release.

For a given buffer there are exactly two kinds of party: one producer, which is the kernel on the owning CPU, and any number of consumers. There is no multi-producer contention to account for.

On x86-64 the producer's release stores compile to plain stores, because the architecture does not reorder stores with other stores. Consumers MUST NOT rely on that: the ordering above is required for correctness on weaker architectures, and a consumer written without it is incorrect on those machines whether or not it is observed to fail on x86-64.

3.1 Scope and Roles

Peios / Advanced Peios / PSPK / Binary Signing and PIP

This chapter specifies how a binary is signed so that the Peios kernel will accept it, and what the trust level a signature confers means.

Two roles participate.

The signer is a userspace program holding a private key. It computes a content hash over a file, signs it, and attaches the signature to that file. The signer role is publicly implementable: a third party building software for Peios, or an organisation operating its own trust tier, MUST be able to produce an acceptable signature from this chapter alone.

The verifier is the Peios kernel. It carries public keys, checks signatures at execution and at library load, and derives a process's Process Integrity Protection identity from whichever key verified. The verifier role is not publicly implementable and is not specified here; this chapter constrains it only where the signer needs guarantees about what it will do.

This chapter covers:

  • the signature blob's encoding
  • where a signature is stored, and the order in which storage locations are consulted
  • exactly which bytes are covered by the signature
  • the signature algorithm, its parameters, and the absence of domain separation
  • how a key is selected during verification, and how a trust tier follows from it
  • the meaning of a PIP identity, and what a signer is asserting by requesting one
  • the guarantees a signer may rely on, and the ones it may not

This chapter does not cover:

  • how the kernel parses, hashes, or verifies — described in the Peios Kernel TRM
  • how PIP is enforced between processes or against objects — likewise the TRM's concern
  • key generation, custody, rotation, or distribution policy
  • the process mitigations that compose with PIP, which are set by a process launcher and are unrelated to signing

3.1.1 Why signing is a contract and not an implementation detail #

A binary's trust level is not something a process can request. There is no runtime interface that confers PIP, no inherited grant from a parent, and no flag at process creation. The only input is the signature on the file being executed, and the only authority is the key that verifies it.

That makes the signature the entire boundary. A signer that produces a byte-for-byte correct blob over the correct bytes obtains a trust tier; one that gets any of it wrong obtains none, silently, because an unverifiable binary executes with no protection rather than failing to execute. There is no error to observe and no diagnostic to read.

A specification is therefore the only thing standing between a signer and a silent, total loss of the property it was trying to obtain.

3.1.2 What the kernel establishes for itself #

The verifier trusts nothing in the artifact except the signature's arithmetic.

The trust tier is not carried in the signature, the file, or any metadata a signer controls. It is a property of the key that verified, looked up in a table compiled into the kernel image. A signer cannot encode, request, or influence the tier it receives; presenting a signature made with a key the kernel does not carry is indistinguishable from presenting no signature at all.

The signature covers the file's content, and the verifier re-derives the content hash itself over a stable size snapshot rather than trusting any length or digest recorded in the artifact.

A verified file is pinned against in-place modification for as long as its inode lives, so a signer MUST NOT assume it can update signed content in place. Replacement by a new inode is the only supported update path.

3.1.3 Relationship to PGSS #

Binary signing is not a conformance requirement in the sense PGSS defines. A system that verifies no signatures, or verifies them against different keys, is still Peios; PIP is additive protection rather than a property every Peios system exhibits.

It is specified here because the contract is public even so. A third party that wants its software to run at a trust tier — or that wants to operate a tier of its own — needs the format written down exactly, and needs to know which of the verifier's behaviours it may depend on.

3.2 Signature Format

Peios / Advanced Peios / PSPK / Binary Signing and PIP

3.2.1 The blob #

A signature is a fixed 3310-byte blob, identical wherever it is stored:

OffsetSizeField
01Version. MUST be 0x01.
13309Raw ML-DSA-65 signature.

Total 3310 bytes exactly. There is no length field, no algorithm identifier, no key identifier, no timestamp and no padding. A blob of any other length MUST be rejected, and so MUST a version byte other than 0x01.

Signers MUST NOT emit any other version. A verifier encountering one MUST treat the file as unsigned rather than attempting a fallback interpretation.

3.2.2 Storage #

Two locations are defined. A verifier MUST consult them in this order.

3.2.2.1 ELF section #

An ELF binary SHOULD carry its signature in a section named exactly .peios.sig. The name comparison covers all eleven bytes including the terminating NUL, so a longer name having .peios.sig as a prefix MUST NOT match.

The section's type MUST be SHT_PROGBITS and its size MUST be exactly 3310. The range [sh_offset, sh_offset + sh_size) MUST lie entirely within the file.

The containing file MUST be ELFCLASS64, MUST be ELFDATA2LSB, and MUST carry EV_CURRENT in e_ident[EI_VERSION]. e_shentsize MUST equal 64. e_shstrndx MUST NOT be SHN_UNDEF and MUST be less than e_shnum. The section header table and the section-name string table MUST both lie entirely within the file.

No alignment or flag requirement applies. sh_addralign, sh_flags, sh_addr, sh_link and sh_info are not inspected, and the section's index and position are unconstrained. Where several sections share the name, the one at the lowest index is used.

A 32-bit or big-endian ELF cannot carry a signature at all: it fails the structural requirements above, and a verifier MUST NOT fall back to the extended attribute for it.

3.2.2.2 Extended attribute #

Any file MAY carry its signature in the extended attribute security.peios.sig. The value MUST be exactly 3310 bytes; any other size MUST be treated as unsigned.

This is the only location available to non-ELF files, and it is available to ELF files that carry no .peios.sig section header.

3.2.2.3 Lookup order and commitment #

A verifier MUST determine the storage location as follows.

  1. Read the first four bytes. A file shorter than four bytes, or whose first four bytes are not \x7fELF, is not ELF: go to step 3.
  2. Parse the ELF structures and scan for a section named .peios.sig. Once such a section header is found, the ELF path is committed: the extended attribute MUST NOT be consulted, whatever happens next. A wrong type, a wrong size, an out-of-range offset, a read failure, a bad version byte or a failed verification all yield "unsigned". A structural failure encountered while parsing MUST commit the path in the same way. The single exception is e_shnum == 0, which does not commit.
  3. Read security.peios.sig. If present and exactly 3310 bytes, use it.
  4. Otherwise the file is unsigned.

The commitment rule is a security requirement rather than an optimisation. Without it, an attacker able to write a malformed ELF section could force fallback to whichever location they more easily controlled.

Where both locations are populated, the ELF section wins. A signer SHOULD NOT populate both.

3.2.3 What is signed #

The message is a 32-byte SHA-256 content hash. Which bytes it covers depends on where the signature is stored, and a signer MUST use the form matching its chosen location.

ELF section source. The hash covers the file with the section's contents replaced by zeros:

SHA-256( file[0 .. sh_offset)
       || 0x00 × sh_size
       || file[sh_offset + sh_size .. file_size) )

Only the section contents are zeroed. The Elf64_Shdr entry describing the section is hashed verbatim, as are the ELF header, the program headers, the section-name string table and every other byte of the file. The section header metadata is therefore integrity-protected along with everything else, which is what stops an attacker relocating or resizing the signature section without invalidating the signature.

This has a direct consequence for how a signer MUST work. The complete file layout — including sh_offset, sh_size, sh_name and the position of the section header table — MUST be final before the hash is computed. The practical sequence is to reserve a 3310-byte .peios.sig section filled with zeros, finalise the layout, hash the file as it then stands, sign the hash, and write the blob into the reserved bytes without touching anything else. Because the reserved region is already zero, the file as hashed and the file as shipped differ only in those 3310 bytes.

Extended attribute source. The hash covers the entire file with no exclusions:

SHA-256( file[0 .. file_size) )

This applies to ELF files reaching the attribute path as well as to non-ELF ones. The ELF-zeroed form is used only when the ELF section is the signature source.

In both cases file_size is a snapshot taken at the start of verification. A verifier MUST re-check the size before returning and MUST discard the result if it changed.

3.2.4 Algorithm #

The signature is ML-DSA-65 as specified in FIPS 204. The public key is 1952 bytes and the signature is 3309 bytes.

Signing is:

ML-DSA.Sign(private_key, content_hash, ctx = "")

and verification is the corresponding ML-DSA.Verify.

This is pure ML-DSA — FIPS 204 Algorithm 2 and Algorithm 3 — and not HashML-DSA, the pre-hashing variant. The message happens to be a 32-byte SHA-256 hash, which pure ML-DSA signs directly. A signer MUST NOT use the pre-hashing variant; a signature produced that way will not verify.

The context string MUST be empty. Signers MUST NOT set a context. Verifiers cannot express one, so a signature produced under a non-empty context simply fails to verify rather than being detected and reported.

It follows that ML-DSA's context field is not available for domain separation between binary signatures and any other Peios signature system. Separation MUST come from using distinct keys.

A signer MUST supply the public key as the raw 1952-byte key, not an SPKI-wrapped DER encoding. From OpenSSL 3.5 or later, the raw key is the trailing 1952 bytes of the DER public key.

3.2.5 Key selection and trust tiers #

The blob carries no key identifier, so a verifier selects a key by exhaustive trial: it tries each key in its table in order and takes the first that verifies.

The trust tier is a property of which key verified — a pip_type and a pip_trust, both unsigned integers — and never of anything the signer encoded. A signer cannot express intent about the tier it wants. It obtains whichever tier the verifier associates with the key it used, and if the verifier carries no matching key the file is simply unsigned.

Consequently:

  • A signer that wants software to run at a tier MUST have its public key present in the verifier's table. Getting a key into that table is a deployment question, not a format question.
  • A signer MUST NOT assume its signature is portable across systems carrying different key tables. The same file may be trusted on one and untrusted on another, with no observable difference in the file.
  • Verification cost is linear in the number of keys, so a verifier MAY reasonably carry few.

3.3 The PIP Contract

Peios / Advanced Peios / PSPK / Binary Signing and PIP

A verified signature confers a PIP identity: a pip_type and a pip_trust, both 32-bit unsigned integers, taken from the key that verified. An unsigned, unverifiable or unrecognised binary confers type 0 and trust 0, which means no protection.

This chapter states what that identity means to a party outside the kernel — what a signer is asserting by obtaining one, what an object owner is asserting by labelling an object, and what may and may not be relied upon.

3.3.1 Dominance #

All PIP enforcement reduces to one comparison:

dominates(caller, target):
    if target.pip_type == 0:
        return true
    return caller.pip_type  >= target.pip_type
       and caller.pip_trust >= target.pip_trust

Both axes are compared numerically. Neither is a closed enumeration: a value carries no meaning beyond its ordering.

Three type values are conventional — 0 for None, 512 for Protected, 1024 for Isolated — but a specification MUST NOT assume they are the only ones, and a party evaluating dominance MUST compare numerically rather than switching on known values.

An unprotected target is dominated by everyone. This is what keeps ordinary processes universally accessible whatever trust values a caller carries, and it means PIP restricts access to protected things rather than granting access to trusted callers.

Dominance is binary. A caller either dominates or does not; there is no partial ordering and no per-operation granularity in PIP itself.

3.3.2 What a signer asserts #

Obtaining a tier is an assertion about the binary, not about what it will do. Specifically, a signer with a key at some tier asserts that the signed file is fit to run at that tier, that its contents are what the signer intended, and that the signer accepts the file being treated as trusted by every dominance comparison on every system carrying that key.

A signer MUST NOT treat a tier as a capability grant. A high tier confers no privilege, no access right and no identity. It protects the process from lower-tier processes and permits it to reach higher-protected objects; it grants nothing on its own.

A signer SHOULD understand that a tier is inherited by children at fork and re-derived at their exec. A protected process that execs an unsigned binary loses protection entirely — protection follows the binary, not the lineage.

3.3.3 What an object owner asserts #

An object opts into PIP protection by carrying a process trust label in its SACL, whose SID has the form S-1-19-{type}-{trust} — the Process Trust authority with exactly two sub-authorities. A SID of any other shape makes the descriptor malformed, and an evaluator MUST reject it rather than guess.

The label's access mask names exactly the rights a non-dominant caller may still receive. A dominant caller is unrestricted by the label.

Two properties matter to anyone authoring one.

There is no default. An object with no trust label is unrestricted by PIP, reachable by any process whatever its identity. Protection is opt-in per object.

Privileges do not compensate. PIP revokes rights that privileges granted, including ACCESS_SYSTEM_SECURITY. There is no relabel equivalent, no administrative override, and no privilege that substitutes for insufficient trust. An object owner labelling an object may rely on this: a non-dominant caller cannot reach the object's SACL to remove the label, which is what makes the protection self-sustaining rather than trivially removable.

3.3.4 What may be relied upon #

A party may rely on the following.

A tier is derived from the signature alone. No parent process, no privilege, no runtime interface and no environment can confer, elevate, or forge one — a compromised process running as SYSTEM cannot grant PIP to an unsigned binary.

Impersonation does not alter it. PIP is read from per-process state rather than from a token, so a service impersonating a client still evaluates its own tier, and a process impersonating a token created for a protected process gains nothing.

A verified file is pinned against in-place modification for as long as its inode remains live. Ordinary, positioned and append writes, truncation by descriptor or pathname, every fallocate mode, and content-mutating and unrecognised ioctls are all refused on it, as is mutation or removal of the signature attribute.

A binary the kernel execs on its own behalf carries at least PeiosTcb trust. Where the kernel spawns a userspace helper for its own purposes rather than at a process's request — resolving a module name, and any comparable kernel-initiated exec — the implementation MUST refuse the exec unless the binary's pip_trust is at least the PeiosTcb level. A party may therefore rely on such a helper being TCB-signed, and on the exec failing rather than proceeding at a lower tier.

The refusal MUST apply equally when no tier could be derived at all. "Could not establish trust" and "is not trusted" reach the same outcome here, so that the requirement cannot be evaded by preventing the derivation from running.

3.3.5 What may not be relied upon #

A party MUST NOT rely on the following.

Execution is not gated, with one exception. A bad, tampered or absent signature costs a binary its tier; it does not prevent execution. PIP determines trust level, not permission to run. Permission to run is the file's own security descriptor.

The exception is the kernel-initiated exec described above, where a tier below PeiosTcb refuses the exec outright. It is confined to that case for a reason: everywhere else there is a requesting process whose own authority bounds what the exec can do, so an untrusted binary can be allowed to run and simply carry no tier. A kernel-initiated exec has no such process behind it — the kernel is acting on its own behalf, at its own authority — so there is no lesser authority to fall back to and nothing to bound the result. A party MUST NOT generalise the exception to ordinary execs.

Absence of a tier is not observable as an error. There is no diagnostic distinguishing "this binary is unsigned", "this signature is malformed" and "this key is not in the table". All three produce a process at type 0 and trust 0 with no indication.

There is no revocation. A signed binary later found to be malicious cannot be invalidated. There is no hash blocklist and no key-scoped revocation. The remedies are removing the file or replacing the verifier's key table.

A tier is not a container boundary. PIP operates inside the kernel's trust boundary. Kernel compromise voids it, DMA-capable hardware bypasses it, and it offers nothing equivalent to hypervisor-based isolation.

Library trust is compared, not merely required. Where a process enables library signature verification, a library has to dominate the loading process, so raising a process's tier narrows the set of libraries it can load. A signer distributing libraries alongside a high-tier program has to sign them at a tier that dominates it.

Scripts take their interpreter's tier. A script executed through a #! line contributes nothing; the tier comes from the interpreter binary. A signer MUST NOT expect signing a script to affect anything, and an object owner MUST NOT treat "runs at a high tier" as evidence that the code being run was signed.

4.1 Scope and Roles

Peios / Advanced Peios / PSPK / Registry Source Interface

This chapter specifies the Registry Source Interface (RSI): the protocol between the Peios kernel's registry subsystem and the userspace processes that store registry data for it.

Two roles participate.

The kernel is LCS, the Layered Configuration Subsystem. It owns the registry namespace, the layer model, access control, watches and transactions, and it holds no storage of its own. There is one kernel.

The source is a userspace process that stores the data for one or more hives and answers the kernel's requests about it. The source role is publicly implementable: any process holding the required privilege MAY register as a source, and the kernel is source-agnostic. A conforming source is the subject of the requirements in this chapter.

The kernel is the party asking. A source is authoritative for the bytes it returns and for nothing else.

This chapter covers:

  • the character device, how a source attaches to it and is recognised, and the source slot lifecycle
  • the framing and encoding of requests and responses, and the rules under which each may be extended
  • every operation the kernel issues, its request payload, its response payload, and its meaning
  • the status vocabulary a source answers with
  • what the kernel validates for itself rather than believing
  • the obligations a conforming source MUST satisfy

This chapter does not cover:

  • The registry data model — hives, keys, path entries, values, layers, tombstones, resolution — which is described in the Peios Kernel TRM. A source does not need it.
  • The system-call and ioctl surface the registry offers to ordinary programs, which is that subsystem's own documentation.
  • The registry backup format, which is specified in its own chapter.
  • How a source stores its data, serves concurrent requests, or computes its answers.

4.1.1 What a source is not #

A source stores and returns. It MUST NOT resolve layers, filter results by visibility, evaluate a Security Descriptor, interpret a path beyond the parent and child names it is given, or dispatch a notification. Every such decision belongs to the kernel, and a source that made one would be making it with less information than the kernel has.

A source never learns the identity of the process on whose behalf a request was issued. Requests carry no caller identity, and there is no mechanism by which a source could obtain one.

4.1.2 What the kernel establishes for itself #

The kernel validates that every response is structurally well-formed, that names are valid, that Security Descriptors parse and satisfy the mask rules, that sequence numbers cannot be from the future, and that metadata blocks cover exactly the GUIDs they should. §4.5 lists these.

It cannot validate meaning. A source is inside the trusted computing base precisely because the kernel has no independent copy of what a source returns: a Security Descriptor granting everyone full access to a sensitive key is a valid Security Descriptor, and the kernel will enforce it.

A source is trusted with the correctness of the registry's access control for the hives it backs. That is the whole trust model, and it is why attaching requires the highest privilege the system has.

4.2 The Channel

Peios / Advanced Peios / PSPK / Registry Source Interface

4.2.1 The device #

A source attaches by opening the character device /dev/pkm_registry.

The open() handler evaluates the calling thread's effective token. The caller MUST hold SeTcbPrivilege and it MUST be enabled, not merely present; open() fails EPERM otherwise. An unprivileged process cannot obtain a descriptor to the device at all.

One open descriptor corresponds to one source connection.

4.2.2 Registration #

Before entering the request loop a source MUST register its hives, by issuing the REG_SRC_REGISTER ioctl on the device fd. The argument is a reg_src_register_args:

FieldTypeDescription
hive_countu32Number of hive entries. MUST be non-zero.
_padu32Reserved. MUST be zero.
max_sequenceu64The highest sequence number persisted anywhere in this source's storage. A single value for the whole source, not per hive.
hives_ptru64Userspace address of an array of hive_count reg_src_hive_entry structures.

Each reg_src_hive_entry:

FieldTypeDescription
name_lenu32Length of the hive name in UTF-8 bytes.
_pad0u32Reserved. MUST be zero.
name_ptru64Userspace address of the hive name. Not null-terminated.
root_guidu8[16]The GUID of this hive's root key. MUST NOT be all-zero.
flagsu32RSI_HIVE_PRIVATE (0x01). All other bits reserved and MUST be zero.
_pad1u32Reserved. MUST be zero.
scope_guidu8[16]The private scope identifier. MUST be all-zero unless RSI_HIVE_PRIVATE is set.

Each hive carries its own name pointer; there is no separate array of names.

The kernel validates, and registration fails if any of the following does not hold:

  • every hive name is valid — UTF-8, no null byte, no separator, non-empty, within the configured component length;
  • no hive name is CurrentUser in any casing, which is reserved;
  • the route identity of each hive — its case-folded name paired with its scope — does not collide with one held by an Active source (EEXIST);
  • no root GUID is all-zero, and the root GUIDs within this request are distinct from each other;
  • a hive without RSI_HIVE_PRIVATE carries an all-zero scope_guid;
  • hive_count is within MaxHivesPerSource and the registered source count is within MaxRegisteredSources (ENOSPC);
  • max_sequence is not U64_MAX, since the kernel MUST be able to allocate above it (EOVERFLOW).

max_sequence initialises the kernel's global sequence counter to at least one above it, so that new writes always outrank anything already persisted. A source MUST report it accurately; under-reporting it allows a new write to collide with a stored entry.

4.2.3 Source slots #

A successful registration creates a source slot, the kernel object owning one connection and its hive set. A slot is Active or Down.

Each registered hive has a stable identity: its case-folded name, its visibility, its scope GUID if private, and its root GUID.

A source crash or an fd close marks the slot Down. It does not unregister the source or retire any hive identity. Down slots keep their identities reserved, and collision checks include them. There is no implicit retirement.

While a slot is Down its hives are unavailable; operations needing a round trip fail, key descriptors held by processes remain valid, and watches remain armed.

4.2.4 Resuming a Down slot #

A new process MAY take over a Down slot. It MUST hold SeTcbPrivilege and it MUST register exactly the same hive set: the same number of hives, and for each of them the same case-folded name, the same visibility, the same scope GUID and the same root GUID.

Partial resume is rejected. In particular:

  • A request whose only mismatch is a different root GUID for an otherwise-matching hive fails ESTALE.
  • Other partial or malformed resume attempts fail EINVAL.
  • A collision with an Active slot fails EEXIST, and takes precedence over ESTALE when both would apply.

A source MUST NOT expect to add hives by resuming a Down slot with a larger set; that is a partial-resume failure. New hives require a new slot.

The kernel authenticates a replacement by SeTcbPrivilege, not by process identity. Process identity cannot survive a crash and restart, so nothing records or compares it.

On a successful resume the slot becomes Active and the kernel replays any pending layer deletions before resuming normal traffic.

4.2.5 Before serving anything #

A source MUST purge orphaned key records before completing registration. An orphaned record is a key with no path entry in any layer, left behind by a key that was unlinked but not dropped before the previous shutdown.

If that cleanup cannot be completed, the source MUST fail registration rather than become Active with known orphans. The kernel does not verify this and cannot: it has no independent view of the source's storage.

On first boot against an empty store, a source MUST create a root key record for each hive it backs, generating a GUID for each and giving it an appropriate default Security Descriptor, and MUST persist them. The root GUIDs it then reports in registration are those. Subsequent startups reuse the persisted ones.

4.3 Message Framing

Peios / Advanced Peios / PSPK / Registry Source Interface

The protocol is binary and multiplexed. All multi-byte integers are little-endian.

4.3.1 The request header #

A request is 22 bytes of header followed by an operation-specific payload.

OffsetSizeField
04total_len
48request_id
122op_code
148txn_id

total_len is the whole message including the header.

request_id matches a response to its request. Request ids are allocated by the kernel, are strictly increasing within one connection, and are never reused while that connection lives — including after a request has timed out.

txn_id is the transaction the operation belongs to, or zero for none. When non-zero, the source MUST process the operation inside that transaction's context.

4.3.2 The response header #

A response is 14 bytes of header followed by a payload. It carries no txn_id.

OffsetSizeField
04total_len
48request_id
122op_code

A source MUST copy request_id from the request into its response, and MUST set op_code to the request's operation code with the high bit set — that is, op_code | 0x8000. Every operation has a named response code for this value.

Every response payload begins with a u32 status at offset 14, so the minimum response size is 18 bytes.

4.3.3 Encoding #

A length-prefixed field is a u32 byte count followed by that many bytes. Strings so encoded are UTF-8 and carry no terminator; a terminator byte counted in the length is a null byte and is therefore invalid. Some length-prefixed fields carry binary rather than text — Security Descriptors and value data — and are not UTF-8.

A GUID is 16 raw bytes.

An array is a u32 count followed by that many entries.

A boolean is one byte.

4.3.4 Extension, and its asymmetry #

Requests and responses extend differently, and a source MUST implement both rules.

A request MAY carry trailing fields beyond those a source recognises. A source MUST skip them, using total_len to find the end of the message. This is how the kernel adds an optional field without an RSI version bump.

A response MUST NOT. The kernel rejects any trailing bytes in a response payload as malformed data. A source MUST emit exactly the payload each operation defines, and no more. Extending the protocol in this direction is done with new operations, never by growing an existing payload.

4.3.5 Reading and writing #

The device fd is message-oriented. total_len frames messages, but a successful I/O call never splits or joins one.

read() returns exactly one complete request. If none is queued, a blocking read waits until one is, or until the fd is closing, in which case it returns 0. Under O_NONBLOCK an empty queue returns EAGAIN. If the caller's buffer is too small for the next queued request, read() returns EMSGSIZE and does not consume it.

write() submits exactly one complete response. The buffer length MUST equal the response header's total_len, and MUST be at least the response header size. A successful write is never short.

The following all fail EINVAL and tear the connection down, marking the source Down:

  • a length shorter than the response header;
  • a length that does not equal total_len;
  • an unknown request_id;
  • a response to a request that has not been delivered;
  • a second response to a request already answered;
  • an op_code that is not the request's with the response bit set;
  • a response written on a descriptor that is not the slot's active one.

A source that cannot frame its own messages correctly is not one whose other answers can be relied on. Ordinary per-operation errors are reported through the status vocabulary and do not tear anything down.

poll() reports the fd readable when at least one complete request is queued, writable while the slot is Active, and POLLHUP | POLLERR when the slot is Down or the fd is closing. An fd that is open but not yet registered reports nothing.

4.3.6 Concurrency and timing #

A source MAY process requests in any order and MUST handle multiple in-flight requests without head-of-line blocking. Responses are matched by request_id, not by arrival order.

The kernel bounds in-flight requests per source by MaxConcurrentRSIRequests, default 256.

A source MUST respond to every request it has read, exactly once, even if the kernel-side caller has already given up. The kernel applies a request timeout — RequestTimeoutMs, default 30 seconds, measured from the moment it first tries to reserve an in-flight slot and covering the whole wait — and a source is not disconnected for exceeding it. Late responses are validated and processed exactly like on-time ones.

A timed-out request remains in the kernel's in-flight table, and keeps occupying one of the source's slots, until the source answers or the connection is torn down. A source that accumulates unanswered requests will exhaust its own concurrency budget.

4.4 Operations

Peios / Advanced Peios / PSPK / Registry Source Interface

Eighteen operations. Every one that returns layer-qualified data MUST return all entries across all layers: a source MUST NOT pre-filter, resolve, or omit. The kernel decides what is effective.

Every response payload begins with a u32 status. Payload fields below are listed after it, in wire order, with no padding between them. "Status only" means the payload is the status and nothing else — 18 bytes in total.

OperationCodeResponse
RSI_LOOKUP0x00010x8001
RSI_CREATE_ENTRY0x00020x8002
RSI_HIDE_ENTRY0x00030x8003
RSI_DELETE_ENTRY0x00040x8004
RSI_ENUM_CHILDREN0x00050x8005
RSI_CREATE_KEY0x00100x8010
RSI_READ_KEY0x00110x8011
RSI_WRITE_KEY0x00120x8012
RSI_DROP_KEY0x00130x8013
RSI_QUERY_VALUES0x00200x8020
RSI_SET_VALUE0x00210x8021
RSI_DELETE_VALUE_ENTRY0x00220x8022
RSI_SET_BLANKET_TOMBSTONE0x00230x8023
RSI_BEGIN_TRANSACTION0x00300x8030
RSI_COMMIT_TRANSACTION0x00310x8031
RSI_ABORT_TRANSACTION0x00320x8032
RSI_FLUSH0x00400x8040
RSI_DELETE_LAYER0x00500x8050

Any other operation code is invalid.

4.4.1 Path operations #

4.4.1.1 RSI_LOOKUP #

Look up a child entry under a parent key. This is the path-walking primitive; the kernel issues one per path component.

Request: parent_guid (16), child_name (length-prefixed).

Response: entry_count (u32), then that many path entries:

SizeField
4+nlayer_name
1target_type: 0 = GUID, 1 = HIDDEN
16target_guid
8sequence

then metadata_count (u32), then that many key metadata entries:

SizeField
16guid
4+nsd, binary
1volatile
1symlink
8last_write_time

An empty response — entry_count zero — means the child does not exist in any layer. That is an ordinary answer, not an error.

The metadata block is deduplicated: exactly one entry per distinct GUID referenced by the path entries. A source MUST satisfy all of:

  • every GUID appearing as a target has exactly one metadata entry;
  • no metadata entry is duplicated;
  • no metadata entry is unreferenced;
  • no metadata GUID is all-zero;
  • a HIDDEN entry MUST carry an all-zero target_guid and MUST NOT contribute a metadata entry.

Violating any of these is malformed data.

The kernel resolves symlink targets itself, by issuing a separate RSI_QUERY_VALUES for the key's default value. A source MUST NOT interpret the symlink flag or follow anything.

4.4.1.2 RSI_CREATE_ENTRY #

Create a path entry (parent, child_name, layer) → guid.

Request: parent_guid (16), child_name, layer_name, child_guid (16), sequence (u64).

Response: status only.

Always paired with RSI_CREATE_KEY, which carries the same GUID. The kernel sends RSI_CREATE_ENTRY first, so that a losing race is detected before a key record is created: an RSI_ALREADY_EXISTS here means another writer got the name.

4.4.1.3 RSI_HIDE_ENTRY #

Create a HIDDEN path entry at (parent, child_name, layer).

Request: parent_guid (16), child_name, layer_name, sequence (u64).

Response: status only.

4.4.1.4 RSI_DELETE_ENTRY #

Remove the path entry at (parent, child_name, layer), whether it was a GUID entry or a HIDDEN one.

Request: parent_guid (16), child_name, layer_name.

Response: status only.

4.4.1.5 RSI_ENUM_CHILDREN #

Enumerate every child entry under a parent, across all layers.

Request: parent_guid (16).

Response: child_count (u32), then per child:

SizeField
4+nchild_name
4entry_count
…that many path entries, in the RSI_LOOKUP entry format

followed by one metadata block, in the RSI_LOOKUP metadata format, covering the distinct GUIDs across all children. Emitting it once rather than per child is what makes this cheaper than a lookup per name.

The same deduplication and closure rules apply, evaluated across the whole response.

4.4.2 Key operations #

4.4.2.1 RSI_CREATE_KEY #

Create a key record. The GUID is assigned by the kernel.

Request: guid (16), name, parent_guid (16), sd (binary, length-prefixed), volatile (1), symlink (1).

Response: status only.

The path entry linking the key into the namespace is created separately by RSI_CREATE_ENTRY. For a symlink key, the target is written afterwards as a default REG_LINK value through RSI_SET_VALUE.

A source MUST persist the GUID exactly as given: no rewriting, no remapping, no reassignment. GUIDs are the kernel's identity for keys and the source's primary key for storage.

4.4.2.2 RSI_READ_KEY #

Request: guid (16).

Response: name, parent_guid (16), sd, volatile (1), symlink (1), last_write_time (i64).

4.4.2.3 RSI_WRITE_KEY #

Update a key's mutable fields.

Request: guid (16), field_mask (u32), then the named fields in bit order.

BitFieldEncoding
0sdlength-prefixed
1last_write_timei64

Only fields whose bit is set are present. Any other bit set in field_mask is invalid.

Response: status only.

There is no way to express a change to the GUID, the volatile flag or the symlink flag: those fields are simply absent from the request. A source MUST reject a request attempting to modify an immutable field with RSI_INVALID.

A field_mask of zero is a well-formed existence check that mutates nothing.

4.4.2.4 RSI_DROP_KEY #

Purge everything associated with a GUID: the key record, all value entries across all layers, all path entries, and all blanket tombstones.

Request: guid (16).

Response: status only.

RSI_DROP_KEY MUST be idempotent. If the GUID does not exist — already purged by startup cleanup, say — the source MUST return RSI_OK.

The kernel issues this when the last descriptor to an unnamed key closes, and it issues it without a waiting caller. A source MUST answer it like any other request.

4.4.3 Value operations #

4.4.3.1 RSI_QUERY_VALUES #

Retrieve every layer entry for one value, or for all values on a key.

Request: guid (16), value_name, query_all (1). When query_all is set the value name is empty and every value on the key is returned.

Response: entry_count (u32), then per entry:

SizeField
4+nvalue_name
4+nlayer_name
4type
4+ndata, binary
8sequence

then blanket_count (u32), then per blanket tombstone:

SizeField
4+nlayer_name
8sequence

The blanket list is part of every response, including one for a single named value: the kernel needs it to resolve that name.

A tombstone entry carries type REG_TOMBSTONE (0xFFFF) and zero-length data. A source MUST NOT return a tombstone with data, an undefined value type, or data exceeding the configured maximum value size.

4.4.3.2 RSI_SET_VALUE #

Store a value entry at (guid, value_name, layer), replacing any existing entry for that triple.

Request: guid (16), value_name, layer_name, type (u32), data, sequence (u64), expected_sequence (u64).

Response: status only.

Conditional writes. A source MUST support expected_sequence. Zero means unconditional. Non-zero means the source MUST atomically verify that the current entry at (guid, value_name, layer) carries that sequence number before writing, and MUST return RSI_CAS_FAILED without writing if it does not match or if no entry exists.

The condition is against the layer's own entry, not against any resolved value. A source does not know what is effective and MUST NOT try to work it out.

4.4.3.3 RSI_DELETE_VALUE_ENTRY #

Remove the entry at (guid, value_name, layer), whether it was a value or a tombstone.

Request: guid (16), value_name, layer_name.

Response: status only.

This operation MUST be idempotent: a source MUST return RSI_OK when there was no entry to remove. The kernel does not mask RSI_NOT_FOUND here, so a source returning it makes the caller's delete fail.

4.4.3.4 RSI_SET_BLANKET_TOMBSTONE #

Set or remove a blanket tombstone on (guid, layer).

Request: guid (16), layer_name, set (1), sequence (u64).

Response: status only.

4.4.4 Transaction operations #

4.4.4.1 RSI_BEGIN_TRANSACTION #

Request: txn_id (u64), mode (u32).

The transaction id appears in the payload. The request header's own txn_id is zero for this operation, since the transaction does not yet exist. RSI_COMMIT_TRANSACTION and RSI_ABORT_TRANSACTION carry it in both places.

ModeValueMeaning
RSI_TXN_READ_WRITE0An ordinary transaction.
RSI_TXN_READ_ONLY1A point-in-time read snapshot.

Response: status only.

For RSI_TXN_READ_WRITE, reads tagged with the id MUST observe the transaction's own uncommitted writes, and writes tagged with it MUST be committed atomically by RSI_COMMIT_TRANSACTION.

For RSI_TXN_READ_ONLY, reads tagged with the id MUST observe a stable point-in-time snapshot. The kernel MUST NOT send a mutating operation with a read-only transaction id, and a source that receives one MUST reject it with RSI_INVALID and MUST NOT mutate anything. A read-only transaction is released with RSI_ABORT_TRANSACTION; the kernel MUST NOT send RSI_COMMIT_TRANSACTION for one.

A source whose store cannot support a mode MAY answer RSI_TXN_NOT_SUPPORTED for that mode. The two are independent: a source MAY support read-only snapshots without supporting read-write transactions.

4.4.4.2 RSI_COMMIT_TRANSACTION #

Request: txn_id (u64).

Response: status only.

On success every change in the transaction MUST be durable. On failure every change MUST be rolled back.

4.4.4.3 RSI_ABORT_TRANSACTION #

Request: txn_id (u64).

Response: status only.

Sent when a transaction is closed without committing, on timeout, and to release a read-only snapshot. The kernel MAY send it without a waiting caller.

4.4.5 Layer operations #

4.4.5.1 RSI_DELETE_LAYER #

Remove everything tagged with a layer name.

Request: layer_name.

Response: orphaned_guid_count (u32), then that many GUIDs (16 each).

The source MUST atomically remove all path entries, all value entries and all blanket tombstones whose layer is layer_name, and MUST NOT remove any key record. Orphan cleanup is the kernel's, through RSI_DROP_KEY.

orphaned_guids MUST list exactly the GUIDs that lost their last path entry as a result, with no nil GUID and no duplicates. The kernel tracks them for deferred deletion.

An unknown layer name is not an error. A source with no entries for it MUST return RSI_OK with an empty orphan list — a layer may have entries in one source and none in another.

4.4.6 Maintenance #

4.4.6.1 RSI_FLUSH #

Persist pending writes for one hive to durable storage.

Request: hive_name.

Response: status only, returned when persistence is confirmed.

This is the only operation that identifies its target by hive name rather than by GUID, because flushing is a hive-level act — a WAL checkpoint, say — not a key-level one.

4.5 Conformance

Peios / Advanced Peios / PSPK / Registry Source Interface

A conforming source MUST satisfy every requirement in this chapter. This section collects the obligations that are not tied to one operation, and the status vocabulary.

4.5.1 Status codes #

Every response payload begins with a u32 status. Zero is success.

StatusCodeKernel maps toMeaning
RSI_OK0successThe operation completed.
RSI_NOT_FOUND1ENOENTA key, value or path entry does not exist.
RSI_ALREADY_EXISTS2EEXISTA path entry or key already exists.
RSI_STORAGE_ERROR3EIOA failure in the backing store.
RSI_NOT_EMPTY4ENOTEMPTYA key still has children or values.
RSI_TOO_LARGE5ENOSPCValue data exceeds the maximum size.
RSI_TXN_BUSY6EBUSYA transaction could not take the write lock.
RSI_INVALID7EINVALA malformed request or an invalid field value.
RSI_CAS_FAILED8EAGAINA conditional write's sequence did not match.
RSI_TXN_NOT_SUPPORTED9ENOTSUPThis transaction mode is not supported.

A source MUST NOT return a code outside this vocabulary. One outside it is malformed data.

Source-specific detail is never surfaced to the process that made the registry call. The status is the whole interface, and a source MUST choose the code that most accurately describes what happened.

4.5.2 Obligations #

Respond to everything. A source MUST send exactly one response for every request it has read, even after the kernel-side caller has timed out. A request the source has read and will never answer occupies an in-flight slot until the connection is torn down.

Return complete layer data. When asked for values or path entries, a source MUST return all layer entries. It MUST NOT pre-filter, resolve, or omit any. Layer resolution is the kernel's.

Order enumerations deterministically. The same request against unchanged hive state MUST return the same ordering every time. This applies to the child list of RSI_ENUM_CHILDREN, the value entries and the blanket tombstone list of RSI_QUERY_VALUES, and the path entries of RSI_LOOKUP. Ascending folded name, then layer, then sequence satisfies it.

This is correctness, not tidiness. The kernel exposes enumeration to callers as a dense index walk — position 0, 1, 2 until exhaustion — observing the source's ordering at each step. A source that returns the same set in a different order across those observations makes the walk revisit some entries and never see others, which surfaces as duplicate and silently missing keys. An unordered SQL query or a hash-map iteration does not satisfy this: UNION ALL without ORDER BY and deliberately randomised map iteration both yield different orderings for identical input.

The obligation constrains ordering within one response only. It does not constrain the order in which concurrent requests are processed, and it does not by itself make a multi-step enumeration atomic against concurrent mutation — a caller that needs a stable view across a whole walk uses a read-only transaction.

Preserve GUIDs exactly. A source MUST persist a GUID as given.

Handle concurrency. A source MUST handle multiple in-flight requests without head-of-line blocking, and MAY process them in any order.

Serialise commits. Concurrent read-write commits MUST be serialised, and a commit MUST be atomic. The kernel does no conflict detection: it relies on commits being ordered and atomic, and lets the later write win by sequence number.

Support conditional writes. expected_sequence on RSI_SET_VALUE MUST be honoured atomically.

Protect immutable fields. RSI_WRITE_KEY requests attempting to modify the GUID, the volatile flag or the symlink flag MUST be rejected with RSI_INVALID.

Create hive roots on first boot, and purge orphans before registering. Both are described in §4.2.

4.5.3 What the kernel validates #

A source cannot rely on a malformed response being tolerated. The kernel checks each of the following, and two categories of failure have different consequences.

Malformed data — a structurally valid message with invalid content — fails the request with EIO, emits an audit event naming the source and the class of failure, and leaves the source running, since corruption may be localised. The classes are: an unparseable or mask-invalid Security Descriptor; an invalid layer name, key name or value name; a payload of the wrong shape or with trailing bytes; a metadata block that is incomplete, duplicated, unreferenced or nil; an invalid value type, a tombstone carrying data, or oversized data; a nil or duplicated orphan GUID; a status code outside the vocabulary; and the two sequence rules below.

Malformed protocol — a structurally invalid message, a framing error, an unknown or duplicate request id, an operation code that does not match its request — is treated as a crash. The connection is torn down and the source is marked Down.

4.5.3.1 The two sequence rules #

A source MUST NOT return a layer-qualified entry whose sequence number is greater than or equal to the next number the kernel would allocate. Sources store the numbers the kernel assigns them and cannot legitimately hold a future one. Without this rule a compromised source could fabricate a sequence number and win every resolution tie in its own hives.

A source MUST NOT return duplicate sequence numbers at the same precedence where they would have to be compared to select a winner. The kernel rejects the response rather than choosing arbitrarily. Duplicates that are never compared are not an error.

4.5.4 The trust boundary #

A source is inside the trusted computing base. The kernel has no independent copy of anything a source returns, so a compromised source controls the access-control outcome for its hives entirely: it can return a permissive Security Descriptor for any key and the kernel will enforce it. Structural validation catches malformed data; it cannot catch data that is well-formed and false.

Three consequences follow that an operator should understand.

A source backing the hive that holds layer metadata can fabricate precedence and enabled values, and so decide which layer wins every resolution contest system-wide. The privilege check applied when precedence is written does nothing about a fabricated read.

The same source can return permissive descriptors for layer metadata keys, granting any process write access to any layer.

SeRestorePrivilege implies descriptor control: a restore replaces every Security Descriptor in the subtree it covers.

Sources MUST therefore run with tightly scoped privileges and be protected by descriptors on their service definitions. The kernel emits an audit event for every source data validation failure.

5.1 Scope and Roles

Peios / Advanced Peios / PSPK / Registry Backup Format

This chapter specifies the registry backup format: the byte stream that represents a registry key and everything beneath it, with full layer fidelity.

Two roles participate, and unlike a live protocol they need not exist at the same time.

The writer produces a stream. The Peios kernel's registry subsystem is one writer; so is any third-party tool that constructs a backup for migration, provisioning or archival.

The reader consumes one. The kernel is a reader, and so is any tool that inspects, converts or transforms a backup.

The format is specified rather than described because both roles are publicly implementable, and because a stream outlives the process that wrote it. A backup taken on one machine is restored on another, by a different implementation, perhaps years later.

This chapter covers:

  • the framing common to every record, and the encoding of its fields
  • the versioning fields, and the rules under which the format may be extended
  • every record type and its payload, in wire order
  • the ordering of records within a stream
  • the integrity trailer and exactly what it covers
  • what a reader MUST validate, and what a restore MUST do with a stream it accepts

This chapter does not cover:

  • The registry data model the stream represents — hives, keys, layers, tombstones, resolution — which is described in the Peios Kernel TRM.
  • The Registry Source Interface, which is a separate chapter. The backup format is a kernel-level format; a registry source never sees one.
  • The system calls that produce and consume a stream.
  • The binary layout of a Security Descriptor or a SID, which is defined in PCDS. A backup carries them as opaque byte strings.

5.1.1 Design constraints #

The format is shaped by five requirements, and an implementation of either role has to respect all of them.

Streamable. A stream is written to an arbitrary descriptor — a file, a pipe, a socket — in a single forward pass, and read back the same way. Neither role may require seeking.

Full layer fidelity. Every path entry, value, tombstone and blanket tombstone carries its layer tag. Restoring reconstructs the layered state, not a flattened view of it.

Depth-first pre-order. A key's parent always appears before it, so a reader can create keys top-down without buffering a tree.

Descriptors inline. Each key record carries its own Security Descriptor, with no deduplication and no shared table. Redundancy is left to external compression.

Self-verifying. A trailer carries a record count and a cryptographic checksum, so truncation and corruption are detectable before anything is acted on.

5.2 Stream Structure

Peios / Advanced Peios / PSPK / Registry Backup Format

All multi-byte integers in this format are little-endian, in the record framing and in every payload.

5.2.1 Record framing #

Every record begins with the same six-byte header.

OffsetSizeField
02record_type
24record_len

record_len is the record's total size including this header, so its minimum valid value is 6. The payload follows immediately.

A reader MUST validate record_len >= 6 and MUST verify that the record body can be read in full before acting on the record or skipping it.

RecordCode
HEADER0x01
LAYER0x02
KEY0x03
PATH_ENTRY0x04
VALUE0x05
BLANKET_TOMBSTONE0x06
TRAILER0xFF

5.2.2 Field encoding #

A length-prefixed field is a u32 byte count followed by that many bytes. Names — hive name, layer name, child name, value name — are UTF-8. Three length-prefixed fields are binary, not UTF-8: a LAYER record's owner SID, a KEY record's Security Descriptor, and a VALUE record's data.

A GUID is 16 raw bytes. An all-zero GUID is nil and is valid only where a record's definition says so.

5.2.3 Ordering #

HEADER                         exactly one, first
LAYER                          one per referenced layer, before any key data
  for each key, depth-first pre-order over the merged tree:
    KEY                        the key object
    PATH_ENTRY *               entries owned by this section
    VALUE *                    all layers' values for this key
    BLANKET_TOMBSTONE *        all layers' blankets for this key
TRAILER                        exactly one, last

The merged tree is the union of every layer's namespace. Depth-first pre-order guarantees a key's parent precedes it.

The following are normative:

  • HEADER MUST be the first record and MUST appear exactly once.
  • Every LAYER record MUST precede all key data. A LAYER record after key data has begun is invalid.
  • The root KEY record — the one whose GUID equals HEADER.RootGUID — MUST be the first KEY record in the stream, and MUST appear exactly once.
  • Within a key's section, records MUST appear in the order PATH_ENTRY, then VALUE, then BLANKET_TOMBSTONE. A PATH_ENTRY after a VALUE or BLANKET_TOMBSTONE in the same section is invalid, as is a VALUE after a BLANKET_TOMBSTONE.
  • No PATH_ENTRY, VALUE or BLANKET_TOMBSTONE may appear before the first KEY record.
  • TRAILER MUST be the last record. Any record after it is invalid.

5.2.4 Which section a path entry belongs to #

A PATH_ENTRY naming a key belongs to that key's section: it is one of the incoming entries for the key whose section it is in.

A HIDDEN entry has no key, so it cannot have a section of its own. It belongs to the section of the key that is its parent, alongside that key's other records. A HIDDEN entry masking a name where no key exists in any layer is still valid — it expresses that a layer hides a name, whatever else is or is not there.

A writer MUST NOT emit a GUID-bearing PATH_ENTRY in the root key's section. On restore, the target key's existing name is authoritative and such a record would be discarded. A reader MUST skip one rather than treat it as an error.

A path entry's parent GUID may belong to a key that only has path entries in a different layer. The merged-tree walk handles that; it is not a special case.

5.2.5 Versioning #

HEADER carries two version numbers.

FormatVersion is the version the stream was written with.

MinReaderVersion is the oldest reader that can process the stream correctly. A reader MUST reject a stream whose MinReaderVersion exceeds its own supported version, before acting on any of it.

The current version is 21 in both fields, and readers support 21.

A writer that used only older features SHOULD set a lower MinReaderVersion, so that older readers can restore the stream. A writer MUST raise MinReaderVersion when a new record type is required for a correct restore, so that an older reader refuses the stream rather than restoring an incomplete one.

5.2.6 Extension #

Extension is by new record types only.

Unknown record types MAY appear anywhere between HEADER and TRAILER. When MinReaderVersion permits, a reader MUST skip them, and MUST treat them as inert: they do not begin or end a key section, do not satisfy any required record, do not declare a layer, and do not affect root mapping, sequence remapping or any validation rule. They do count toward TRAILER.RecordCount and they are covered by the checksum.

A record payload MUST be consumed exactly. Trailing bytes inside a record of a known type are invalid, even though record_len would accommodate them. A reader MUST NOT skip unrecognised trailing data within a known record, and a writer MUST NOT add any.

This is deliberately the opposite of the RSI's request convention. A stream is replayed into mutations long after it was written, and a field silently ignored there is data silently lost.

5.3 Records

Peios / Advanced Peios / PSPK / Registry Backup Format

Payload fields are listed in wire order, immediately after the six-byte framing header, with no padding between them.

5.3.1 HEADER — 0x01 #

Exactly one, first in the stream. Fixed portion 44 bytes plus the hive name.

SizeFieldDescription
8MagicThe ASCII bytes PEIOSREG — 50 45 49 4F 53 52 45 47.
4FormatVersionu32.
4MinReaderVersionu32.
8Timestampi64, Unix nanoseconds.
16RootGUIDThe GUID of the key at the root of this backup.
4+nHiveNameThe hive the backup was taken from.

A reader MUST reject a stream whose magic does not match, and MUST reject one whose MinReaderVersion exceeds its own supported version.

HiveName MUST be a valid hive name under the ordinary naming rules.

RootGUID is a stream-local identity for the backup root. On restore it is remapped to the target key (§5.4).

5.3.2 LAYER — 0x02 #

One per layer name that has layer-tagged data anywhere in the stream. All LAYER records precede all key data.

SizeFieldDescription
4+nNameThe layer name.
4Precedenceu32, as observed at backup time.
1Enabledu8. MUST be 0 or 1.
4+nOwnerBinary SID, as observed at backup time.

A LAYER record is a stream manifest entry, not a backup of the layer's definition. It records what the layer looked like when the backup was taken so that a restore can validate the stream against it, and it creates, updates, deletes, enables, disables and authorises nothing.

A layer's definition is backed up only when its metadata subtree is itself inside the exported subtree, in which case it appears as ordinary KEY, PATH_ENTRY and VALUE records like anything else.

A reader MUST validate that every layer name is valid, that folded layer identities are unique within the manifest, that Enabled is 0 or 1, that Owner parses as a SID, and that every layer name appearing in a PATH_ENTRY, VALUE or BLANKET_TOMBSTONE has exactly one corresponding LAYER record.

5.3.3 KEY — 0x03 #

One per distinct key object in the subtree, however many layers name it.

SizeFieldDescription
16GUIDThe key's identity. MUST NOT be nil.
4Flagsu32. Bit 0 volatile, bit 1 symlink.
4SDLengthu32.
nSDThe full Security Descriptor.
8LastWriteTimei64, Unix nanoseconds.

Name and parent GUID are absent. They are derivable from the PATH_ENTRY records in the key's own section, and carrying them separately would let a stream contradict itself.

Undefined bits in Flags MUST be zero. A reader MUST reject a record with any bit outside 0x03 set, rather than ignoring it.

SD MUST parse as a Security Descriptor and MUST have an owner.

5.3.4 PATH_ENTRY — 0x04 #

One per name-to-key mapping per layer.

SizeFieldDescription
16ParentGUIDThe parent key. MUST NOT be nil.
4+nChildNameThe name under that parent.
16ChildGUIDThe key being named, or an all-zero GUID meaning HIDDEN.
4+nLayerNameThe layer this entry belongs to.
8Sequenceu64.

ChildGUID is the only GUID field in the format that may be nil, and a nil one means HIDDEN rather than "no key". No KEY record is emitted for the zero GUID.

5.3.5 VALUE — 0x05 #

One per value entry per layer, tombstones included.

SizeFieldDescription
16KeyGUIDThe key this value belongs to. MUST NOT be nil.
4+nNameThe value name; empty for the default value.
4Typeu32.
4DataLengthu32.
nDataThe value's bytes.
4+nLayerNameThe layer this entry belongs to.
8Sequenceu64.

Type MUST be one of the defined registry value types, or REG_TOMBSTONE (0xFFFF). A tombstone MUST carry zero-length data.

5.3.6 BLANKET_TOMBSTONE — 0x06 #

One per blanket tombstone per layer.

SizeFieldDescription
16KeyGUIDThe key this blanket belongs to. MUST NOT be nil.
4+nLayerNameThe layer.
8Sequenceu64.

5.3.7 TRAILER — 0xFF #

Exactly one, last. Payload 40 bytes, so the whole record is 46.

SizeFieldDescription
8RecordCountu64. Every record in the stream, HEADER and TRAILER included.
32ChecksumSHA-256.

RecordCount MUST be at least 2 — a stream has at minimum a header and a trailer.

5.3.7.1 What the checksum covers #

The checksum is a SHA-256 over the bytes from the start of the HEADER record's framing header through the end of TRAILER.RecordCount, inclusive.

That is: every byte of every preceding record, then the trailer's own six-byte framing header, then the eight bytes of RecordCount. The 32 checksum bytes themselves are not covered, and nothing follows them.

Skipped unknown records are covered, in full, like any other.

A reader MUST verify both RecordCount and Checksum. A stream whose record count does not match, or whose checksum does not verify, MUST be rejected.

5.4 Restoring

Peios / Advanced Peios / PSPK / Registry Backup Format

Restoring is a replace, not a merge. The target key's contents and descendants are removed before the stream's contents are written.

The whole operation — teardown and rebuild together — MUST be atomic. There is no partial-restore mode, and a store that cannot offer atomicity cannot be a restore target.

5.4.1 The target key survives #

A restore is performed against a key that already exists. That key object is not replaced: its GUID, its parent, its name, its volatile flag and its symlink flag remain what they were and MUST NOT be taken from the stream.

What the stream's root KEY record supplies is the mutable part — the Security Descriptor and the last write time — which MUST be written to the target inside the restore.

The root record's immutable flags MUST match the target's. A backup of a volatile key restored onto a non-volatile one, or a symlink onto a non-symlink, MUST be rejected.

5.4.2 Root remapping #

HEADER.RootGUID is stream-local. Every reference to it — a PATH_ENTRY's ParentGUID or ChildGUID, a VALUE or BLANKET_TOMBSTONE's KeyGUID — MUST be remapped to the target key's existing GUID before any validation of parent references and before any record is applied.

The backup root GUID MUST NOT be created as a new key record.

Descendant KEY records keep their backup GUIDs, which are written into the target verbatim.

5.4.3 GUID rules #

  • The stream MUST contain exactly one KEY record whose GUID equals HEADER.RootGUID, and it MUST be the first KEY record.
  • A non-root GUID MUST NOT appear twice in the stream.
  • A non-root GUID MUST NOT equal the restore target's GUID.
  • A non-root GUID that already exists outside the subtree being replaced is a collision and the restore MUST fail. A reader is not required to detect this before beginning; it MAY surface during replay, in which case the atomicity requirement ensures nothing is left behind.

5.4.4 Parent validation #

Before a path entry is applied, its ParentGUID — after root remapping — MUST be either the restore target's GUID or the GUID of a non-root KEY record already processed earlier in the stream. A parent outside the stream's remapped key set MUST cause the restore to fail.

This is what prevents a crafted backup from injecting path entries into arbitrary parts of the existing namespace, outside the subtree being replaced. It is not optional.

A HIDDEN PATH_ENTRY is held to a stricter rule: its remapped ParentGUID MUST equal the GUID of the section it appears in, not merely some already-processed key.

5.4.5 Creating a key #

A KEY record carries no name and no parent, so both come from the section's path entries.

The anchor is the first GUID-bearing PATH_ENTRY in the section, in stream order, whose remapped ChildGUID equals the KEY record's GUID. Its remapped ParentGUID and its ChildName are the parent and name the key is created with.

  • If the section contains no GUID-bearing path entry targeting the KEY record's GUID, the restore MUST fail.
  • If any GUID-bearing PATH_ENTRY in a non-root section targets a different GUID after remapping, the restore MUST fail.
  • HIDDEN entries do not satisfy the anchor requirement. They are parent-owned records for the key being created and are replayed only after it exists.

The key's LastWriteTime MUST be written immediately after it is created, before any of the section's other records are replayed.

Path entries for the root section are handled differently: GUID-bearing ones are not restored, because the target key's existing incoming path entries remain authoritative. HIDDEN entries in the root section are parent-owned records for the restore root and MUST be restored, after parent validation.

5.4.6 Sequence remapping #

The backup's sequence numbers preserve its internal layer-resolution ordering. A restore is a new mutation, and its entries MUST become newer than everything already present while keeping that internal order. A reader MUST NOT write backup sequence numbers through unchanged.

Remapping MUST preserve streamability: it MUST NOT require a seekable input or a pre-scan pass. Before the first layer-qualified record is applied, the reader records an offset — the next sequence number it would allocate — and then, for every restored layer-qualified record:

new_sequence = restore_sequence_offset + backup_sequence

That needs no lookahead: the running maximum is computed during the single pass.

The offset is held stable for the duration of the restore, so that no other sequence-allocating mutation can interleave. Reads are not blocked by it. A restore containing no layer-qualified record at all need not reserve one.

If a remapped value would reach or exceed U64_MAX, the restore MUST fail. The valid remapped range is [offset, U64_MAX); U64_MAX itself is never a valid sequence number.

When the restore reaches any terminal state — success, abort, failure or cancellation — the global counter MUST be advanced past the highest number the restore dispatched, and that advance MUST NOT be rolled back. Numbers a failed restore dispatched become unused gaps, exactly like those of any other failed write.

Dispatch order remains structural stream order. It is the remapped numbers, not the order they were sent in, that preserve the backup's internal layer resolution.

5.4.7 Layers in a restored stream #

LAYER manifest records define nothing (§5.3). If restored entries reference a layer that is not in the live layer table, and the stream does not also restore that layer's metadata subtree as ordinary registry data, those entries become latent unknown-layer entries and are ignored during resolution until real metadata exists. If the metadata subtree is included, it is restored through the ordinary path, and those records are what define the layer.

5.4.8 Privilege #

A restore replaces every Security Descriptor in the subtree it covers, so the privilege to perform one effectively confers descriptor control over everything within its reach.

Before any key record is written, a reader MUST check the layer manifest: if any declared layer has a precedence above 0, or any existing layer with the same folded identity does, the caller MUST hold the privilege that guards high-precedence layers, or the restore MUST be aborted before a single byte is written.

Records in the stream that create or raise persisted layer metadata above precedence 0 are subject to the same check as an ordinary write would be. Neither check is redundant: the first covers what the manifest declares, the second covers what the stream actually writes.

5.4.9 Validation before mutation #

A reader MAY validate the entire stream — including the trailer's record count and checksum — before applying any of it. Doing so is stronger than this specification requires, and it means a corrupt stream is rejected before anything is torn down rather than after. The cost is memory rather than seeking, since the records must be retained across the teardown.

A reader that instead validates as it goes MUST still guarantee that a checksum failure leaves nothing applied, which the atomicity requirement already demands.

Peios Learn — documentation for the Peios project.

Built with Trail.