I've since found a solution to the problem, but it's not pretty and it raises all sorts of new problems you have to deal with. Still, it works. I'm posting the solution here for my own reference and for others who may have need for it in the future.
The problem is that a user's registry hive is in their user profile folder (e.g. c:\users\username) in a file called NTUSER.DAT. However, a user's user profile folder isn't created until they log in, so when you create a new user there's no user profile yet and no NTUSER.DAT file containing their registry hive, so you can't edit any of their registry settings.
There's a trick, though: the user profile does get created when you run something under that user's credentials. There's an executable called runas.exe that lets you run a second executable under a specified user's credentials. If you create a new user and make it run, say, cmd.exe, like so:
runas /user:newuser cmd.exe
...it'll open a Cmd instance, but more importantly, create newuser's profile in the \users folder, including NTUSER.DAT.
Now, Cmd.exe leaves a command window open, which you can close manually but it's kind of clunky. https://superuser.com/a/389288 pointed me to rundll32.exe which, when run without any parameters, does nothing and exits immediately. Also, it's available on every Windows installation.
So, by calling runas and telling it to run rundll32.exe as the new user, we can create the user's profile without any further interaction:
Process.Start("runas", string.Format("/user:{0} rundll32", "newuser"));
Well... almost with no interaction. Runas opens a console window that requires you to enter the user's password, even if no password is set (it wants you to just press enter). This is annoying, but can be solved with some clever use of Pinvoke and optionally System.Windows.Forms to bring the window to the foreground and send it some keypresses:
var createProfileProcess = Process.Start("runas",
string.Format("/user:{0} rundll32",
"newuser"));
IntPtr hWnd;
do
{
createProfileProcess.Refresh();
hWnd = createProfileProcess.MainWindowHandle;
} while (hWnd.ToInt32() == 0);
SetForegroundWindow(hWnd);
System.Windows.Forms.SendKeys.SendWait("{ENTER}");
This creates the profile, waits until the window has a handle, and then calls the Win32 function SetForegroundWindow() to bring it to the foreground. Then, it uses SendKeys.SendWait to send an enter key to that window. If you don't want to use a WinForms DLL, there are Win32 functions you can PInvoke for this, but for this particular scenario I found the winforms way quicker and easier.
This works, but reveals yet another problem: runas won't let you run stuff under an account that has no password. Superuser to the rescue again; https://superuser.com/a/470539 points out that there's a Local Policy called Limit local account use of blank passwords to console logon only that can be disabled to allow this exact scenario. I didn't want users to have to manually disable this policy, so I used the corresponding registry value LimitBlankPasswordUse in HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Lsa.
I now disable the policy by setting the registry value to 0, run the runas command to create the profile, then re-enable the policy by setting the value to 1 afterwards.(It would probably be cleaner to check the value first and only re-enable it if it was set in the first place, but for demonstration purposes this will do:
const string keyName = "HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Lsa";
Registry.SetValue(keyName, "LimitBlankPasswordUse", 0);
var createProfileProcess = Process.Start("runas",
string.Format("/user:{0} rundll32",
"newuser"));
IntPtr hWnd;
do
{
createProfileProcess.Refresh();
hWnd = createProfileProcess.MainWindowHandle;
} while (hWnd.ToInt32() == 0);
SetForegroundWindow(hWnd);
System.Windows.Forms.SendKeys.SendWait("{ENTER}");
Registry.SetValue(keyName, "LimitBlankPasswordUse ", "1");
This works! However, the user's registry hive isn't loaded yet, so you still won't be able to read or write to it. For that, the process needs a couple of privileges, which you can again provide using some Win32 methods:
OpenProcessToken(GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
out _myToken);
LookupPrivilegeValue(null, SE_RESTORE_NAME, out _restoreLuid);
LookupPrivilegeValue(null, SE_BACKUP_NAME, out _backupLuid);
_tokenPrivileges.Attr = SE_PRIVILEGE_ENABLED;
_tokenPrivileges.Luid = _restoreLuid;
_tokenPrivileges.Count = 1;
_tokenPrivileges2.Attr = SE_PRIVILEGE_ENABLED;
_tokenPrivileges2.Luid = _backupLuid;
_tokenPrivileges2.Count = 1;
AdjustTokenPrivileges(_myToken,
false,
ref _tokenPrivileges,
0,
IntPtr.Zero,
IntPtr.Zero);
AdjustTokenPrivileges(_myToken,
false,
ref _tokenPrivileges2,
0,
IntPtr.Zero,
IntPtr.Zero);
And finally load the hive using the new user's SID:
// Load the hive
var principalContext = new PrincipalContext(ContextType.Machine);
var standardUser = UserPrincipal.FindByIdentity(principalContext, "newuser");
var sid = standardUser.Sid.ToString();
StringBuilder path = new StringBuilder(4 * 1024);
int size = path.Capacity;
GetProfilesDirectory(path, ref size);
var filename = Path.Combine(path.ToString(), "newuser", "NTUSER.DAT");
Thread.Sleep(2000);
int retVal = RegLoadKey(HKEY_USERS, sid, filename);
I found most of this code in Load registry hive from C# fails.
RegLoadKey should return 0 on success. I noted that occasionally, it would fail to load the hive for no apparent reason. Reasoning that perhaps the necessary files in the user profile had not yet been created, I added a Thread.Sleep(2000) before loading the hive to give Windows time to create all the necessary files. There's probably a neater way to do this, but for now this'll work.
Now, you can load and set registry values for newuser using the newuser's SID, for instance:
var subKeyString = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Advanced";
var keyString = string.Format("{0}{1}", sid, subKeyString);
var subKey = Registry.Users.CreateSubKey(keyString,
RegistryKeyPermissionCheck.ReadWriteSubTree);
subKey.SetValue("EnableBalloonTips", 0, RegistryValueKind.DWord);
Just to be sure, I also unloaded the registry hive when I was done. I'm not sure if it's required, but it seems like the neat thing to do:
var retVal = RegUnLoadKey(HKEY_USERS, GetSidForStandardUser());