Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 61 additions & 6 deletions Core/Helpers/IsolatedUserManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,18 @@
/// - Sin driver kernel, sin licencias, sin binarios externos.
///
/// SEGURIDAD:
/// - Contraseña aleatoria (24 chars, 4 clases) ROTADA en cada arranque y nunca
/// persistida en disco — solo vive en memoria durante el arranque de la sesión.
/// - Contraseña aleatoria (24 chars, 4 clases), guardada como secreto local de LSA
/// (<see cref="IsolatedUserSecret"/>, solo legible por SYSTEM) y reutilizada en cada
/// arranque. No se rota: restablecer la contraseña de una cuenta local deja inservibles
/// sus claves DPAPI, y con ellas el inicio de sesión guardado de Steam y las contraseñas
/// de los navegadores. Solo se restablece si no hay secreto o ya no vale.
/// - Usuario oculto de la pantalla de login (SpecialAccounts\UserList).
/// - Miembro únicamente de Users + Remote Desktop Users. Donde no existe ese grupo
/// (Windows Home), recibe directamente el derecho de inicio de sesión remoto y
/// acceso a la escucha RDP-Tcp; se le quitan al borrar la cuenta.
///
/// CICLO DE VIDA:
/// - EnsureUser → crea el usuario (o rota la contraseña si ya existe).
/// - EnsureUser → crea el usuario (o reutiliza su contraseña si ya existe).
/// - TryDeleteProfile → borra C:\Users\osms-... + hive de registro (modo efímero).
/// - RemoveUser → perfil + cuenta + entrada de registro (al eliminar la sesión,
/// salvo que se pida conservarlo para reutilizarlo en otra).
Expand Down Expand Up @@ -71,6 +74,16 @@
[DllImport("netapi32.dll")]
static extern int NetApiBufferFree(IntPtr buffer);

[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern bool LogonUser(string user, string domain, string password, int logonType, int provider, out IntPtr token);

[DllImport("kernel32.dll")]
static extern bool CloseHandle(IntPtr handle);

const int LOGON32_LOGON_NETWORK = 3;
const int LOGON32_PROVIDER_DEFAULT = 0;
const int ERROR_LOGON_FAILURE = 1326;

[DllImport("userenv.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern bool DeleteProfile(string lpSidString, string? lpProfilePath, string? lpComputerName);

Expand Down Expand Up @@ -128,9 +141,9 @@
}

/// <summary>
/// Crea el usuario local dedicado de la sesión (o rota su contraseña si ya existe),
/// lo añade a Users + Remote Desktop Users y lo oculta de la pantalla de login.
/// Devuelve credenciales listas para FreeRDP. La contraseña no se persiste nunca.
/// Crea el usuario local dedicado de la sesión (o reutiliza su contraseña guardada si ya
/// existe), lo añade a Users + Remote Desktop Users y lo oculta de la pantalla de login.
/// Devuelve credenciales listas para FreeRDP. Ver <see cref="IsolatedUserSecret"/>.
/// </summary>
internal static IsolatedCredentials EnsureUser(string username, string sessionName)
{
Expand All @@ -153,15 +166,24 @@
{
case NERR_Success:
Logger.Log(BackendText.Get("isolatedUser.userCreated", username, sessionName));
KeepPassword(username, password);
break;

case NERR_UserExists:
// La de la vez anterior, si sigue valiendo: así no se pierden las claves DPAPI.
if (TryGetSecret(username) is { } kept && PasswordWorks(username, kept))
{
password = kept;
Logger.Log(BackendText.Get("isolatedUser.passwordKept", username));
break;
}
var reset = new USER_INFO_1003 { usri1003_password = password };
int rcSet = NetUserSetInfo(null, username, 1003, ref reset, out _);
if (rcSet != NERR_Success)
throw new InvalidOperationException(
BackendText.Get("isolatedUser.passwordRotationFailed", username, rcSet));
Logger.Log(BackendText.Get("isolatedUser.passwordRotated", username));
KeepPassword(username, password);
break;

default:
Expand All @@ -180,6 +202,37 @@
return new IsolatedCredentials(username, password);
}

static string? TryGetSecret(string username)
{
try { return IsolatedUserSecret.TryGet(username); }
catch (Exception ex)
{
Logger.Warning(BackendText.Get("isolatedUser.passwordStoreFailed", username, ex.Message));
return null;
}
}

static void KeepPassword(string username, string password)
{
try { IsolatedUserSecret.Store(username, password); }
catch (Exception ex) { Logger.Warning(BackendText.Get("isolatedUser.passwordStoreFailed", username, ex.Message)); }
}

/// <summary>
/// La contraseña sigue siendo la de la cuenta. Inicio de sesión de red: no crea perfil ni
/// sesión. Solo "usuario o contraseña incorrectos" cuenta como no válida; otro error (por
/// ejemplo, que la directiva no permita ese tipo de inicio) ya implica que era correcta.
/// </summary>
static bool PasswordWorks(string username, string password)
{
if (LogonUser(username, ".", password, LOGON32_LOGON_NETWORK, LOGON32_PROVIDER_DEFAULT, out var token))
{
CloseHandle(token);
return true;
}
return Marshal.GetLastWin32Error() != ERROR_LOGON_FAILURE;
}

/// <summary>
/// Borra el perfil del usuario aislado (directorio + hive NTUSER.DAT) vía
/// DeleteProfileW. Devuelve true si el perfil quedó eliminado o no existía.
Expand Down Expand Up @@ -231,7 +284,7 @@
return;
}

Logger.Log(BackendText.Get("isolatedUser.profileNotReleased", username, error, attempt));

Check warning on line 287 in Core/Helpers/IsolatedUserManager.cs

View workflow job for this annotation

GitHub Actions / Build installer

Possible null reference argument for parameter 'args' in 'string BackendText.Get(string key, params object[] args)'.
}

Logger.Warning(BackendText.Get("isolatedUser.profileDeleteFailed", username));
Expand Down Expand Up @@ -280,13 +333,15 @@
Logger.Warning(BackendText.Get("isolatedUser.netUserDelFailed", username, rc));

UnhideFromLoginScreen(username);
try { IsolatedUserSecret.Delete(username); }
catch (Exception ex) { Logger.Warning(BackendText.Get("isolatedUser.passwordStoreFailed", username, ex.Message)); }

// Justo tras el logoff profsvc aún tiene el perfil cargado y DeleteProfile falla:
// antes eso dejaba la carpeta C:\Users\osms-... para siempre. Se reintenta por SID,
// que sigue valiendo aunque la cuenta ya no exista.
if (!profileGone && sid is not null)
{
Logger.Log(BackendText.Get("isolatedUser.profileStillLoaded", username, error));

Check warning on line 344 in Core/Helpers/IsolatedUserManager.cs

View workflow job for this annotation

GitHub Actions / Build installer

Possible null reference argument for parameter 'args' in 'string BackendText.Get(string key, params object[] args)'.
_ = DeleteProfileBySidWithRetryAsync(sid, username);
}
}
Expand Down Expand Up @@ -410,8 +465,8 @@
else if (key.StartsWith("sid:", StringComparison.Ordinal))
{
if (!TryDeleteProfileBySid(key[4..], out var error))
throw new InvalidOperationException(BackendText.Get("isolatedUser.profileDeleteError", entry.Name, error));

Check warning on line 468 in Core/Helpers/IsolatedUserManager.cs

View workflow job for this annotation

GitHub Actions / Build installer

Possible null reference argument for parameter 'args' in 'string BackendText.Get(string key, params object[] args)'.
Logger.Log(BackendText.Get("isolatedUser.profileDeleted", entry.ProfilePath));

Check warning on line 469 in Core/Helpers/IsolatedUserManager.cs

View workflow job for this annotation

GitHub Actions / Build installer

Possible null reference argument for parameter 'args' in 'string BackendText.Get(string key, params object[] args)'.
}
else
{
Expand Down
142 changes: 142 additions & 0 deletions Core/Helpers/IsolatedUserSecret.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
using System.ComponentModel;
using System.Runtime.InteropServices;

namespace OpenStreamMS.Core.Helpers;

/// <summary>
/// Contraseña de cada usuario aislado (<c>osms-…</c>), guardada como secreto local de LSA
/// (<c>L$OpenStreamMS_&lt;usuario&gt;</c>): Windows lo cifra y solo lo puede leer SYSTEM.
/// <para>
/// Por qué se guarda: restablecer la contraseña de una cuenta local (lo que hacía cada arranque)
/// deja inservibles sus claves DPAPI, que dependen de la contraseña. Steam guarda su inicio de
/// sesión con DPAPI, así que recordaba la cuenta pero pedía QR o contraseña cada vez; lo mismo
/// pasaba con las contraseñas guardadas en los navegadores de la sesión.
/// </para>
/// </summary>
internal static class IsolatedUserSecret
{
[StructLayout(LayoutKind.Sequential)]
private struct LSA_UNICODE_STRING
{
public ushort Length;
public ushort MaximumLength;
public IntPtr Buffer;
}

[StructLayout(LayoutKind.Sequential)]
private struct LSA_OBJECT_ATTRIBUTES
{
public int Length;
public IntPtr RootDirectory, ObjectName;
public int Attributes;
public IntPtr SecurityDescriptor, SecurityQualityOfService;
}

[DllImport("advapi32.dll")] private static extern uint LsaOpenPolicy(IntPtr system, ref LSA_OBJECT_ATTRIBUTES attrs, int access, out IntPtr handle);
[DllImport("advapi32.dll")] private static extern uint LsaStorePrivateData(IntPtr policy, ref LSA_UNICODE_STRING key, IntPtr data);
[DllImport("advapi32.dll")] private static extern uint LsaRetrievePrivateData(IntPtr policy, ref LSA_UNICODE_STRING key, out IntPtr data);
[DllImport("advapi32.dll")] private static extern uint LsaFreeMemory(IntPtr buffer);
[DllImport("advapi32.dll")] private static extern uint LsaClose(IntPtr handle);
[DllImport("advapi32.dll")] private static extern int LsaNtStatusToWinError(uint status);

private const int POLICY_CREATE_SECRET = 0x00000020;
private const int POLICY_GET_PRIVATE_INFORMATION = 0x00000004;
private const uint STATUS_OBJECT_NAME_NOT_FOUND = 0xC0000034;

/// <summary>Nombre del secreto: "L$" lo hace local (nunca sale del equipo ni se replica).</summary>
internal static string KeyName(string username) => "L$OpenStreamMS_" + username.ToLowerInvariant();

/// <summary>La contraseña guardada para <paramref name="username"/>, o null si no hay.</summary>
internal static string? TryGet(string username)
{
return WithPolicy(policy => WithKey(username, (ref LSA_UNICODE_STRING key) =>
{
uint st = LsaRetrievePrivateData(policy, ref key, out var data);
if (st == STATUS_OBJECT_NAME_NOT_FOUND) return null;
Check(st, "LsaRetrievePrivateData");
try
{
if (data == IntPtr.Zero) return null;
var s = Marshal.PtrToStructure<LSA_UNICODE_STRING>(data);
return s.Length == 0 ? null : Marshal.PtrToStringUni(s.Buffer, s.Length / 2);
}
finally
{
if (data != IntPtr.Zero) LsaFreeMemory(data);
}
}));
}

/// <summary>Guarda (o sustituye) la contraseña de <paramref name="username"/>.</summary>
internal static void Store(string username, string password)
{
WithPolicy(policy => WithKey(username, (ref LSA_UNICODE_STRING key) =>
{
var buffer = Marshal.StringToHGlobalUni(password);
var value = new LSA_UNICODE_STRING
{
Length = (ushort)(password.Length * 2),
MaximumLength = (ushort)(password.Length * 2),
Buffer = buffer,
};
var valuePtr = Marshal.AllocHGlobal(Marshal.SizeOf<LSA_UNICODE_STRING>());
try
{
Marshal.StructureToPtr(value, valuePtr, false);
Check(LsaStorePrivateData(policy, ref key, valuePtr), "LsaStorePrivateData");
return (string?)null;
}
finally
{
Marshal.FreeHGlobal(valuePtr);
Marshal.ZeroFreeGlobalAllocUnicode(buffer);
}
}));
}

/// <summary>Borra el secreto de <paramref name="username"/> (si no existe, no hace nada).</summary>
internal static void Delete(string username)
{
WithPolicy(policy => WithKey(username, (ref LSA_UNICODE_STRING key) =>
{
uint st = LsaStorePrivateData(policy, ref key, IntPtr.Zero);
if (st != STATUS_OBJECT_NAME_NOT_FOUND) Check(st, "LsaStorePrivateData");
return (string?)null;
}));
}

private delegate string? KeyAction(ref LSA_UNICODE_STRING key);

private static string? WithKey(string username, KeyAction action)
{
var name = KeyName(username);
var buffer = Marshal.StringToHGlobalUni(name);
try
{
var key = new LSA_UNICODE_STRING
{
Length = (ushort)(name.Length * 2),
MaximumLength = (ushort)((name.Length + 1) * 2),
Buffer = buffer,
};
return action(ref key);
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}

private static string? WithPolicy(Func<IntPtr, string?> action)
{
var attrs = new LSA_OBJECT_ATTRIBUTES { Length = Marshal.SizeOf<LSA_OBJECT_ATTRIBUTES>() };
Check(LsaOpenPolicy(IntPtr.Zero, ref attrs, POLICY_CREATE_SECRET | POLICY_GET_PRIVATE_INFORMATION, out var policy), "LsaOpenPolicy");
try { return action(policy); }
finally { LsaClose(policy); }
}

private static void Check(uint status, string what)
{
if (status != 0) throw new Win32Exception(LsaNtStatusToWinError(status), what);
}
}
4 changes: 3 additions & 1 deletion Services/Localization/Locales/bg.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,7 @@
"isolatedUser.profileDeleteError": "Не може да се изтрие профилът '{0}': {1}.",
"isolatedUser.orphanFolderDeleted": "Останала папка '{0}' изтрита.",
"isolatedUser.errorAddingToGroup": "Не може да се добави '{0}' към група '{1}': грешка {2}.",
"isolatedUser.errorRemovingRights": "Не може да се премахнат правата за '{0}': {1}."
"isolatedUser.errorRemovingRights": "Не може да се премахнат правата за '{0}': {1}.",
"isolatedUser.passwordKept": "Потребителят '{0}' вече съществуваше; паролата му се запазва (Steam и другите запазени влизания остават валидни).",
"isolatedUser.passwordStoreFailed": "Паролата на '{0}' не можа да бъде запазена или прочетена в LSA: {1}. Тя ще бъде нулирана при следващото стартиране."
}
4 changes: 3 additions & 1 deletion Services/Localization/Locales/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,7 @@
"isolatedUser.profileDeleteError": "Nelze smazat profil '{0}': {1}.",
"isolatedUser.orphanFolderDeleted": "Zbylá složka '{0}' smazána.",
"isolatedUser.errorAddingToGroup": "Nepodařilo se přidat '{0}' do skupiny '{1}': chyba {2}.",
"isolatedUser.errorRemovingRights": "Nepodařilo se odebrat práva pro '{0}': {1}."
"isolatedUser.errorRemovingRights": "Nepodařilo se odebrat práva pro '{0}': {1}.",
"isolatedUser.passwordKept": "Uživatel '{0}' již existoval; jeho heslo se zachovává (Steam a další uložená přihlášení zůstávají platná).",
"isolatedUser.passwordStoreFailed": "Heslo uživatele '{0}' nelze uložit ani přečíst v LSA: {1}. Při příštím spuštění bude obnoveno."
}
4 changes: 3 additions & 1 deletion Services/Localization/Locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,7 @@
"isolatedUser.profileDeleteError": "Profil '{0}' konnte nicht gelöscht werden: {1}.",
"isolatedUser.orphanFolderDeleted": "Verwaistes Verzeichnis '{0}' gelöscht.",
"isolatedUser.errorAddingToGroup": "'{0}' konnte nicht zur Gruppe '{1}' hinzugefügt werden: Fehler {2}.",
"isolatedUser.errorRemovingRights": "Rechte für '{0}' konnten nicht entfernt werden: {1}."
"isolatedUser.errorRemovingRights": "Rechte für '{0}' konnten nicht entfernt werden: {1}.",
"isolatedUser.passwordKept": "Benutzer '{0}' war bereits vorhanden; sein Kennwort bleibt erhalten (Steam und andere gespeicherte Anmeldungen bleiben gültig).",
"isolatedUser.passwordStoreFailed": "Das Kennwort von '{0}' konnte in LSA nicht gespeichert oder gelesen werden: {1}. Es wird beim nächsten Start zurückgesetzt."
}
4 changes: 3 additions & 1 deletion Services/Localization/Locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,7 @@
"isolatedUser.profileDeleteError": "Could not delete profile '{0}': {1}.",
"isolatedUser.orphanFolderDeleted": "Orphan folder '{0}' deleted.",
"isolatedUser.errorAddingToGroup": "Could not add '{0}' to group '{1}': error {2}.",
"isolatedUser.errorRemovingRights": "Could not remove rights for '{0}': {1}."
"isolatedUser.errorRemovingRights": "Could not remove rights for '{0}': {1}.",
"isolatedUser.passwordKept": "User '{0}' already existed; its password is kept (Steam and other saved logins stay valid).",
"isolatedUser.passwordStoreFailed": "Could not save or read the password of '{0}' in LSA: {1}. It will be reset on the next start."
}
4 changes: 3 additions & 1 deletion Services/Localization/Locales/en_GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,7 @@
"isolatedUser.profileDeleteError": "Could not delete profile '{0}': {1}.",
"isolatedUser.orphanFolderDeleted": "Orphan folder '{0}' deleted.",
"isolatedUser.errorAddingToGroup": "Could not add '{0}' to group '{1}': error {2}.",
"isolatedUser.errorRemovingRights": "Could not remove rights for '{0}': {1}."
"isolatedUser.errorRemovingRights": "Could not remove rights for '{0}': {1}.",
"isolatedUser.passwordKept": "User '{0}' already existed; its password is kept (Steam and other saved logins stay valid).",
"isolatedUser.passwordStoreFailed": "Could not save or read the password of '{0}' in LSA: {1}. It will be reset on the next start."
}
4 changes: 3 additions & 1 deletion Services/Localization/Locales/en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,7 @@
"isolatedUser.profileDeleteError": "Could not delete profile '{0}': {1}.",
"isolatedUser.orphanFolderDeleted": "Orphan folder '{0}' deleted.",
"isolatedUser.errorAddingToGroup": "Could not add '{0}' to group '{1}': error {2}.",
"isolatedUser.errorRemovingRights": "Could not remove rights for '{0}': {1}."
"isolatedUser.errorRemovingRights": "Could not remove rights for '{0}': {1}.",
"isolatedUser.passwordKept": "User '{0}' already existed; its password is kept (Steam and other saved logins stay valid).",
"isolatedUser.passwordStoreFailed": "Could not save or read the password of '{0}' in LSA: {1}. It will be reset on the next start."
}
4 changes: 3 additions & 1 deletion Services/Localization/Locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,7 @@
"isolatedUser.profileDeleteError": "No se pudo borrar el perfil '{0}': {1}.",
"isolatedUser.orphanFolderDeleted": "Carpeta huérfana '{0}' eliminada.",
"isolatedUser.errorAddingToGroup": "No se pudo añadir '{0}' al grupo '{1}': error {2}.",
"isolatedUser.errorRemovingRights": "No se pudieron quitar los derechos de '{0}': {1}."
"isolatedUser.errorRemovingRights": "No se pudieron quitar los derechos de '{0}': {1}.",
"isolatedUser.passwordKept": "Usuario '{0}' ya existía; se conserva su contraseña (Steam y otros inicios de sesión guardados siguen valiendo).",
"isolatedUser.passwordStoreFailed": "No se pudo guardar o leer la contraseña de '{0}' en LSA: {1}. Se restablecerá en el siguiente arranque."
}
4 changes: 3 additions & 1 deletion Services/Localization/Locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,7 @@
"isolatedUser.profileDeleteError": "No se pudo borrar el perfil '{0}': {1}.",
"isolatedUser.orphanFolderDeleted": "Carpeta huérfana '{0}' eliminada.",
"isolatedUser.errorAddingToGroup": "No se pudo añadir '{0}' al grupo '{1}': error {2}.",
"isolatedUser.errorRemovingRights": "No se pudieron quitar los derechos de '{0}': {1}."
"isolatedUser.errorRemovingRights": "No se pudieron quitar los derechos de '{0}': {1}.",
"isolatedUser.passwordKept": "L'utilisateur '{0}' existait déjà ; son mot de passe est conservé (Steam et les autres connexions enregistrées restent valides).",
"isolatedUser.passwordStoreFailed": "Impossible d'enregistrer ou de lire le mot de passe de '{0}' dans LSA : {1}. Il sera réinitialisé au prochain démarrage."
}
4 changes: 3 additions & 1 deletion Services/Localization/Locales/hu.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,7 @@
"isolatedUser.profileDeleteError": "A '{0}' profil nem törölhető: {1}.",
"isolatedUser.orphanFolderDeleted": "Ártatlan mappa '{0}' törölve.",
"isolatedUser.errorAddingToGroup": "A '{0}' nem adható hozzá a '{1}' csoporthoz: hiba {2}.",
"isolatedUser.errorRemovingRights": "A '{0}' jogai nem vonhatók ki: {1}."
"isolatedUser.errorRemovingRights": "A '{0}' jogai nem vonhatók ki: {1}.",
"isolatedUser.passwordKept": "A(z) '{0}' felhasználó már létezett; a jelszava megmarad (a Steam és más mentett bejelentkezések érvényesek maradnak).",
"isolatedUser.passwordStoreFailed": "Nem sikerült menteni vagy olvasni a(z) '{0}' jelszavát az LSA-ban: {1}. A következő indításkor alaphelyzetbe áll."
}
Loading
Loading