(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.
@loopBody @checkBit7 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 @checkBit6 ; 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 @get32BitOffset ; 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 @gotOffset ; 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 @forLoopCheck 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 @gotAuthBuffer 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?
@topOfMainForLoop 320 4A86 Tst.L D6 322 6604 BNE.B @pickRandomChunk 324 7800 MoveQ.L #0, D4 326 6016 Bra.B @readChunk @pickRandomChunk 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 @readChunk 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:
- Get the size of the target volume.
- Integer divide this size into 128K chunks. Call the total number of chunks N.
- Allocate 80 bytes for a file header.
- 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.
- Allocate 16 bytes for the signature size S.
- 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.
- 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.
- Preallocate a blank version of this file on the target volume.
- 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.
- 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).
- 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.
- 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.
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 @nextMainForLoopIteration 3A8 5286 AddQ.L #1, D6 @endOfMainForLoop 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:
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.