Exploring the Pippin ROM(s), part 6: Back in the ‘rvpr’

(How’s that for a punny subtitle?)

It’s been a while. I haven’t lost interest, I’ve just been busy with work and other things. Life has a funny way of sneaking up on you. 😉

The wait is worth it, though. Buckle up; this one’s a doozy.

Apple hasn’t documented the Pippin’s authentication process beyond what developers needed to know. There exists a technote that was distributed via the SDK(s) that gives an overview of what developers were expected to do to get their discs signed before final mastering and duplication. The Pippin’s authenticated boot process hinges upon the presence of a specially-crafted, RSA-signed file unique to each disc called the “PippinAuthenticationFile.” Since the Pippin platform was abandoned and subsequently cancelled in 1998, Apple no longer signs Pippin discs nor have they made available the means for third parties to do so. To my knowledge, most of the specifics of how the PippinAuthenticationFile plays a role in the Pippin’s boot process have never been documented outside of Apple.

That changes today.

This post is pretty dense, so I highly recommend (re-)reading parts 1 through 4 for some background before getting too deep. Otherwise, here’s a quick recap: during every boot, a retail Pippin console locates a potential boot volume on CD, loads an ‘rvpr’ 0 resource from ROM, then calls the code therein in order to verify that the target volume passes an authentication check allowing it to boot the system. (An aside: Previously, I asserted that while I found identical copies of ‘rvpr’ 0 in the 1.2 and 1.3 ROMs, I couldn’t find an entry for it in the resource map, therefore it must either be dead code or called some other way. This conclusion turned out to be incorrect—the resource map is not contiguous in ROMs 1.2 and 1.3, which made manually searching it more difficult, but it does indeed contain an entry for ‘rvpr’ 0. The authentication process is therefore identical between ROM 1.0 and 1.2.) When I last looked at ‘rvpr’ 0, I was stymied by a routine called upon entry which, absent of any symbols to help point me toward its purpose, I conjectured used a complex block of data at the end of the resource to “decrypt” the code therein. After taking a closer look a few days ago, I was delighted to find that its purpose is much simpler—it exists to patch the absolute memory locations in the code so they are relative to the buffer where ‘rvpr’ 0 is loaded. Without these patches, the code would crash the Pippin on boot practically every time!

The way this routine accomplishes this is kind of elegant. We initialize a cursor pointer to the beginning of our buffer where ‘rvpr’ 0 is loaded. The offset table starting at offset $8A47 from the start of ‘rvpr’ 0 begins with a 32-bit longword defining the size of the table. Then, the table itself is compressed: a byte with bit 7 set means it’s a relative sign-extended 7-bit offset from our cursor position, a byte with bit 6 set means it along with the next byte form a sign-extended 14-bit offset from our cursor position, but if both bit 6 and 7 are clear, then combine the next three bytes to form a 30-bit absolute cursor position. Multiply these offsets by two before applying them (because 68K opcodes are always at least two bytes), add the address of our ‘rvpr’ 0 buffer to the 32-bit longword pointed to by our cursor, then repeat the process until we’ve exhausted the offset table. Easy peasy.

5E   1218         Move.B   (A0)+, D1   ; grab the next byte into D1, we'll call it the command byte
60   1001         Move.B   D1, D0
62   0240 0080    AndI     #$80, D0    ; is bit 7 set?
66   670C         BEQ.B    @checkBit6  ; then handle the bit 6 case

; command byte bit 7 is set, so
; D2 += signExtend(D1 * 2) as a byte (* 2 because alignment)
68   D201         Add.B    D1, D1
6A   1001         Move.B   D1, D0
6C   4880         Ext      D0
6E   48C0         Ext.L    D0
70   D480         Add.L    D0, D2
72   6028         Bra.B    @gotOffset

; else, command byte bit 7 not set...
74   1E81         Move.B   D1, (A7)         ; put D1 into the highest byte of temp
76   1F58 0001    Move.B   (A0)+, $1(A7)    ; grab the next byte into the 2nd byte of temp
7A   1001         Move.B   D1, D0
7C   0240 0040    AndI     #$40, D0         ; is bit 6 of D1 set?
80   670C         BEQ.B    @get32BitOffset  ; yes? then

; command byte bit 6 is set, so
; our address offset is only 14 bits
82   3017         Move     (A7), D0    ; grab the new temp into D0
84   E548         LsL      #2, D0      ; D0 <<= 2
86   E240         AsR      #1, D0      ; D0 /= 2
88   48C0         Ext.L    D0          ; sign extend it
8A   D480         Add.L    D0, D2      ; D0 is the found offset * 2 (because alignment), add to our current offset
8C   600E         Bra.B    @gotOffset  ; apply it

; bit 6 not set...
8E   1F58 0002    Move.B   (A0)+, $2(A7)  ; grab the next byte into the 3rd byte of temp
92   1F58 0003    Move.B   (A0)+, $3(A7)  ; grab the next byte into the 4th byte of temp
96   2417         Move.L   (A7), D2       ; D2 is a brand new offset!
98   E58A         LsL.L    #2, D2         ; D2 <<= 2
9A   E282         AsR.L    #1, D2         ; D2 /= 2

; D2 == the offset we want to apply to argument 2
; D6 == the offset we want to apply to the longword found there (typically @start)
9C   DDB1 2800    Add.L    D6, $0(A1,D2.L)  ; add D6 to the longword at (@start + D2)
A0   5385         SubQ.L   #1, D5
A2   4A85         Tst.L    D5               ; are we out of longs to patch?
A4   6EB8         BGT.B    @loopBody

Now that we've "unpacked" the code of 'rvpr' 0, let's dig into it. 🙂

main starts off initializing a number of globals, first by calling InitRSAAlgorithmChooser and then a handful of other subroutines. It then initializes some local variables on the stack: previous A4, values related to the PippinAuthenticationFile, and a ParamBlockRec for calls to _Read. In addition, among those locals is a 16-byte temporary buffer for digests created during the main loop.

Recall from part 2 that the Pippin ROM passes as input to 'rvpr' 0 the following arguments: two pointers to some as-of-yet-unknown data in ROM shortly preceding the callsite, the ID of the boot volume candidate, and the refNum of the candidate's disk driver. After we've initialized our variables, we hit the ground running by calling GetVolAuthFileInfo to fetch the offset to and size of the PippinAuthenticationFile. Note that if at any point during 'rvpr' 0 one of its internal subroutines fails, the entire process is reported as having failed the authentication check.

24C   41EE FFAA         Lea.L     -$56(A6), A0    ; A0 -> temp var for created digests
250   2E08              Move.L    A0, D7          ; D7 == ptr to temp digest
252   486E FFCA         Pea.L     -$36(A6)        ; pass size out address
256   486E FFBE         Pea.L     -$42(A6)        ; pass offset out address
25A   3F05              Move      D5, -(A7)       ; $10(A6) is dqDrive passed in from ROM
25C   3F2E 0012         Move      $12(A6), -(A7)  ; $12(A6) is dqRefNum passed in from ROM
260   4EB9 0000 03E6    Jsr       GetVolAuthFileInfo
266   3600              Move      D0, D3
268   4A43              Tst       D3
26A   4FEF 000C         Lea.L     $C(A7), A7
26E   6600 0142         BNE       @mainCleanup    ; if GetVolAuthFileInfo returns nonzero, fail
272   202E FFBE         Move.L    -$42(A6), D0    ; D0 = offset from start of HFS volume to PippinAuthenticationFile in allocation blocks
276   7209              MoveQ.L   #9, D1
278   E3A8              LsL.L     D1, D0          ; D0 = offset from start of HFS volume to PippinAuthenticationFile in bytes
27A   2D40 FFC6         Move.L    D0, -$3A(A6)    ; save the offset into -$3A(A6)
27E   202E FFCA         Move.L    -$36(A6), D0    ; D0 = size of the PippinAuthenticationFile in bytes
282   A11E              _NewPtr
284   2648              MoveA.L   A0, A3
286   200B              Move.L    A3, D0
288   4A80              Tst.L     D0
28A   660E              BNE.B     @gotAuthBuffer  ; if _NewPtr returns null, clean up the stack, and fail
28C   554F              SubQ      #2, A7
28E   3EB8 0220         Move      (MemErr), (A7)
292   301F              Move      (A7)+, D0
294   3600              Move      D0, D3
296   6000 011A         Bra       @mainCleanup

29A   3D6E 0012 FFE6    Move      $12(A6), ioRefNum(A6)
2A0   3D45 FFE4         Move      D5, ioVRefNum(A6)         ; dqDrive
2A4   2D4B FFEE         Move.L    A3, ioBuffer(A6)
2A8   2D6E FFCA FFF2    Move.L    -$36(A6), ioReqCount(A6)  ; size of the PippinAuthenticationFile
2AE   3D7C 0001 FFFA    Move      #fsFromStar, ioPosMode(A6)
2B4   202E FFBE         Move.L    -$42(A6), D0              ; offset from start of HFS volume to PippinAuthenticationFile in device blocks
2B8   7209              MoveQ.L   #9, D1
2BA   E3A8              LsL.L     D1, D0                    ; get the offset in bytes by multiplying by device block size (512 bytes)
2BC   2D40 FFFC         Move.L    D0, ioPosOffset(A6)
2C0   41EE FFCE         Lea.L     -$32(A6), A0
2C4   A002              _Read
2C6   3600              Move      D0, D3
2C8   4A43              Tst       D3
2CA   6600 00E6         BNE       @mainCleanup              ; if _Read returns something other than noErr, fail

Following this call, we allocate enough space for the file by calling _NewPtr. We then call _Read with our local ParamBlockRec filled with the disk driver refNum, the volume refNum, the pointer to the buffer we just allocated, our buffer's size, and the byte offset to the authentication file on our candidate. Armed with the contents of the PippinAuthenticationFile, we then pass them to VerifyDigestInfo to verify the signature contained therein. If that succeeds, we're clear to start verifying the candidate's contents against this file, so we allocate temp space large enough to load a single "chunk" of data from the candidate to be hashed and verified.

Every Pippin except the KMP 2000 shipped with a built-in 4x speed CD-ROM drive. A 1x CD-ROM drive can read data at a rate of 150KB/sec, which is the speed necessary for smooth playback of audio CDs. A 2x drive doubles that rate to 300KB/sec, a 4x drive quadruples it to 600KB/sec, and so on. At 600KB/sec, it would take a Pippin almost a full minute to read just over 35MB, and nearly 20 minutes to read the entire contents of a 700MB CD-ROM. Even the KMP 2000 with its 8x drive would take almost 10 minutes to do the same. Hashing the entire contents of a CD during every boot would be unacceptable at this speed, and since the Pippin only takes a couple seconds to verify a disc at startup, it's clearly not verifying the whole thing. So what does the Pippin do?

320   4A86               Tst.L     D6
322   6604               BNE.B     @pickRandomChunk
324   7800               MoveQ.L   #0, D4
326   6016               Bra.B     @readChunk

328    202B 004C         Move.L    $4C(A3), D0  ; longword after the 128K size field at $48, appears to be number of entries in table
32C    5380              SubQ.L    #1, D0
32E    2F00              Move.L    D0, -(A7)    ; upper bound == total number of chunks
330    4878 0001         Pea.L     ($1)         ; lower bound == 1
334    4EB9 0000 0814    Jsr       RangedRand   ; patched
33A    2800              Move.L    D0, D4       ; D4 == pseudorandom integer between [1, <total number of chunks>]?
33C    504F              AddQ      #8, A7       ; clean up stack

33E    2004              Move.L    D4, D0               ; D0 == pseudorandom integer in the lowword, probably
340    2205              Move.L    D5, D1               ; D1 == 128K? 0 x 0002 0000
342    4EB9 0000 0116    Jsr       _D0timesD1           ; patched, does some weird multiplication, returns in D0
348    2D40 FFFC         Move.L    D0, ioPosOffset(A6)  ; D0 == the pseudorandom integer * 128K? D0 == offset to random 128K chunk in disc?
34C    41EE FFCE         Lea.L     -$32(A6), A0
350    A002              _Read
352    3600              Move      D0, D3
354    4A43              Tst       D3
356    665A              BNE.B     @mainCleanup

Put simply, the Pippin randomly spot-checks the candidate volume's contents every boot. The PippinAuthenticationFile isn't just a key, it isn't just a single hash—it is in fact a collection of hashes corresponding to as many 128K chunks of data that make up the boot volume. main enters a loop that iterates six times: the first check, it loads the first 128K of the volume containing important metadata about the HFS filesystem into our temporary buffer, and then verifies that data against its corresponding digested hash previously loaded from the PippinAuthenticationFile. The remaining five checks, it does the same, but on randomly selected other 128K chunks of the volume. This way, the Pippin only has to load and verify 768K—a process that takes less than a couple seconds on its 4x CD-ROM drive. But because this loop selects five of those six input chunks at random each run-through, the PippinAuthenticationFile still needs digests of the entire volume. For it's not known ahead of time which five chunks will be verified and furthermore, they rarely will be the same five chunks.

Examining several PippinAuthenticationFile examples with this code in mind quickly reveals how this file is structured. Both the chunk size and the total number of chunks in the volume are stored in a common header. This loop uses that information to determine the upper bound of which chunks to select at random and how large. Following these two fields is a table of digested 128-bit hashes corresponding, in sequential order, to the chunks in the volume. Finally, there is a signature near the end of the file, which gets verified in the call to VerifyDigestInfo before entering the loop. The process by which a PippinAuthenticationFile is created, therefore, is essentially as follows:

  1. Get the size of the target volume.
  2. Integer divide this size into 128K chunks. Call the total number of chunks N.
  3. Allocate 80 bytes for a file header.
  4. Multiply N chunks by the 16-byte size of each digest (N * 16). Call this table size T. Allocate T bytes for digests of each 128K chunk.
  5. Allocate 16 bytes for the signature size S.
  6. Pad the signature size until it is a multiple of 16 bytes (((S + 16) % 16) *16). Call this padded size P. Allocate P bytes for the signature itself.
  7. Pad additional bytes until the total file size is the next multiple of the device block size (512 bytes). The total file size therefore should be ((80 + T + 16 + P + 512) % 512) * 512.
  8. Preallocate a blank version of this file on the target volume.
  9. Starting at offset 80, compute and store a 16-byte (128-bit) digest for each of the N sequential 128K chunks. Note that to compute the digests for the entire finalized volume correctly, this file must already exist in the filesystem. It is therefore necessary to compute the size of this file in advance, preallocate a "dummy" version of it on the target volume, then compute the digests and overwrite the file in-place.
  10. At offset 80 + T + 15, store the signature size S as a byte (only one byte at the end of this space is actually used, the rest are zeroes).
  11. At offset 80 + T + 16 + 3, store the signature itself. The signature always seems to be 45 bytes long, placed such that it ends on a 16-byte boundary, explaining the extra 3-byte offset.
  12. Fill in the file header at the beginning of the file:
    • offset 0 (4 bytes): offset to signature size byte (80 + T + 15)
    • offset 4 (4 bytes): longword equal to zero (version?)
    • offset 8 (64 bytes): copyright notice (60 bytes, zero-padded right)
    • offset 72 (4 bytes): chunk size longword equal to 128K, or $20000
    • offset 76 (4 bytes): chunk count longword equal to N

Apple probably provided a tool that automated this process for stamping houses. Said tool presumably would have named the aforementioned file "PippinAuthenticationFile" with type/creator 'PpnV'/'PpnA' and saved it to the filesystem root. I imagine that this same tool likely would have filled the file's contents in-place with the signed version received from Apple. However, I have never seen such a tool in the wild so this is pure speculation on my part.

Incidentally, the name, placement within the folder hierarchy, and type/creator codes of the authentication file itself are inconsequential. The Pippin makes no HFS calls to locate the PippinAuthenticationFile—it could technically be buried within a nest of folders or named "FoobarAuthenticationFile." The verification code does not care. Instead, it fetches the Master Directory Block—512 bytes located at byte offset 1024 from the start of the boot volume. The "logical" MDB is a data structure 161 bytes in size and found immediately at the start of this "physical" MDB. However, that leaves 351 bytes unaccounted for. For Pippin CD-ROMs, Apple chose to set aside two 32-bit longwords at the end of the physical MDB for the purpose of locating the PippinAuthenticationFile at the block level. The first of these longwords defines the offset, in 512-byte blocks from the start of the volume, to the contents of the authentication file. The second of these longwords define the authentication file's size in bytes.

(As an aside, this mechanism is one reason why deleting the PippinAuthenticationFile and naively replacing it with a new version at the filesystem level is not likely to work. The new file would likely reside starting at a different allocation block in the volume; the offset in the MDB would still point to where the deleted file was/is, and HFS wouldn't know to patch it up—why should it?)

One important component of creating the authentication file, and verifying against it, is the concept of chunk "cleansing." Once the loop selects and loads a chunk, it passes it to CleanseInputChunk to optionally "cleanse" it. What does that mean in this context?

358   2F2E FFCA         Move.L    -$36(A6), -(A7)         ; size of the PippinAuthenticationFile in bytes
35C   2F2E FFC6         Move.L    -$3A(A6), -(A7)         ; offset from start of HFS volume to PippinAuthenticationFile in bytes
360   2F05              Move.L    D5, -(A7)               ; 128K
362   2F2E FFFC         Move.L    ioPosOffset(A6), -(A7)  ; the computed offset
366   2F0A              Move.L    A2, -(A7)               ; working chunk buffer
368   4EB9 0000 049A    Jsr       CleanseInputChunk       ; patched, remove the auth file from this chunk if we happened to land on it

The digested hashes contained within the authentication file do not include hashing the authentication file itself, for obvious reasons. Similarly, certain fields in the MDB should not be hashed because they change upon writing the final signed authentication file to the volume. "Cleansing" solves this problem by zeroing out these areas before hashing and digesting them. While creating an authentication file, the blocks of the volume containing the file itself should be zeroed out when hashing, and likewise most of the MDB. Upon loading a chunk, the verification loop checks its offset to see whether the chunk overlaps the MDB or the authentication file. If so, it zeroes out that data so that the digested hash matches the corresponding one stored in the authentication file.

I was going to recreate this diagram in Illustrator, but then I remembered I suck at Illustrator.

Finally, the loop passes the chunk to CreateDigest, and compares its result byte-for-byte with the digest in the authentication file by calling CompareDigests. If all six digested chunks match what's found in the authentication file, we pass the check (and can boot from this volume! Yay!). Otherwise, we return -1 to indicate failure.

36E   2F07              Move.L    D7, -(A7)       ; D7 -> out buffer for temp digest
370   2F05              Move.L    D5, -(A7)       ; chunk size (128K)
372   2F0A              Move.L    A2, -(A7)       ; cleansed working chunk buffer
374   4EB9 0000 06EE    Jsr       CreateDigest    ; patched
37A   3600              Move      D0, D3
37C   4A43              Tst       D3
37E   4FEF 0020         Lea.L     $20(A7), A7     ; clean up stack
382   662E              BNE.B     mainCleanup
384   2F07              Move.L    D7, -(A7)       ; D7 -> the digest we just created
386   2004              Move.L    D4, D0          ; D4 == which chunk this is
388   E988              LsL.L     #4, D0          ; D4 == chunk * 16 bytes (128 bit hash per chunk)
38A   206E FFC2         MoveA.L   -$3E(A6), A0    ; A0 -> chunk hashes
38E   D1C0              AddA.L    D0, A0          ; A0 -> hash of this chunk
390   4850              Pea.L     (A0)
392   4EB9 0000 07D4    Jsr       CompareDigests  ; patched, returns zero in D0 if digests match
398   7200              MoveQ.L   #0, D1
39A   1200              Move.B    D0, D1
39C   3601              Move      D1, D3
39E   4A43              Tst       D3
3A0   504F              AddQ      #8, A7          ; clean up stack
3A2   6704              BEQ.B     @nextMainForLoopIteration
3A4   76FF              MoveQ.L   #-1, D3         ; D3 == -1, fail
3A6   600A              Bra.B     mainCleanup

3A8    5286              AddQ.L    #1, D6

3AA    7006              MoveQ.L   #6, D0
3AC    BC80              Cmp.L     D0, D6
3AE    6D00 FF70         BLT       @topOfMainForLoop

So what's left?

Of the named functions I found in part 2, only four of them I have yet to step through and understand:

main done
GetVolAuthFileInfo done
CleanseInputChunk done
VerifyDigestInfo in progress
VerifySignature to do
CreateDigest in progress
CompareDigests done
RangedRand done
CleanseVCB done
GetVAFileInfoGivenMDB done
InitRSAAlgorithmChooser in progress

Astute readers may notice that I have provided sparse details about the functions related to dealing with digests and signatures so far. Those are next on my list but will also be the hardest to grok because I'll be more or less "flying solo" without any symbols whatsoever to guide me. Fortunately I have a passing familiarity with the RSA algorithm so I have a vague idea of what logic to look for. I have already found functions for 32-bit multiply and 32-bit modulo, both of which are essential for RSA.

In part 4 I explored hacking the Apple Partition Map to load custom disk drivers before the authentication check takes effect. I was not successful with that experiment and decided not to pursue it further, but my discoveries here reveal a possible alternate avenue to explore: additional partitions. The authentication check is performed on the boot volume, and only the boot volume (emphasis on each word). The partition map is not included in the check, nor are any other partitions. It should be totally possible to take an existing signed Pippin disc with unpartitioned free space available (for example, the "Tuscon" disc) and graft an additional HFS partition containing whatever apps or documents one might want. The OS should mount the other partition as it would normally, without performing any checks beyond what would be done on a real Mac.

I leave that as an exercise for the reader. I'm going after the big fish: authoring an entire homebrew disc from scratch.