using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.ShareLinks.Models;
using MediaBrowser.Common.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.ShareLinks.Services;
///
/// Generates raw share tokens and their persisted HMAC hashes.
///
public sealed class ShareTokenService
{
private readonly string _secretPath;
private readonly ILogger _logger;
private readonly SemaphoreSlim _secretGate = new(1, 1);
private byte[]? _secretKey;
/// Initializes a new instance of the class.
public ShareTokenService(IApplicationPaths applicationPaths, ILogger logger)
{
_secretPath = Path.Combine(applicationPaths.DataPath, "sharelinks", "token-secret.key");
_logger = logger;
}
/// Creates a new 256-bit token and its HMAC hash.
public async Task GenerateAsync(CancellationToken cancellationToken = default)
{
var secret = await GetSecretAsync(cancellationToken).ConfigureAwait(false);
var tokenBytes = new byte[32];
RandomNumberGenerator.Fill(tokenBytes);
var token = Base64UrlEncode(tokenBytes);
var hash = ComputeHash(secret, tokenBytes);
return new ShareTokenMaterial
{
Token = token,
TokenHash = hash
};
}
///
/// Computes the stored hash for a presented token, or if the token
/// is missing or not well-formed base64url (treated as "no match" rather than an error).
///
public async Task HashTokenAsync(string token, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(token))
{
return null;
}
byte[] tokenBytes;
try
{
tokenBytes = Base64UrlDecode(token);
}
catch (FormatException)
{
return null;
}
var secret = await GetSecretAsync(cancellationToken).ConfigureAwait(false);
return ComputeHash(secret, tokenBytes);
}
/// Validates a token against an expected hash.
public async Task VerifyTokenAsync(string token, string expectedHash, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(expectedHash))
{
return false;
}
var actualHash = await HashTokenAsync(token, cancellationToken).ConfigureAwait(false);
if (actualHash is null)
{
return false;
}
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(actualHash),
Encoding.UTF8.GetBytes(expectedHash));
}
private async Task GetSecretAsync(CancellationToken cancellationToken)
{
if (_secretKey is not null)
{
return _secretKey;
}
await _secretGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_secretKey is not null)
{
return _secretKey;
}
if (File.Exists(_secretPath))
{
try
{
var secretText = await File.ReadAllTextAsync(_secretPath, cancellationToken).ConfigureAwait(false);
_secretKey = Base64UrlDecode(secretText.Trim());
if (_secretKey.Length >= 16)
{
// Also applied on load so a key written by an older build
// stops being world readable.
RestrictToOwner(_secretPath);
return _secretKey;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ShareLinks: could not load the token secret; a new one will be generated.");
}
}
var generated = new byte[32];
RandomNumberGenerator.Fill(generated);
Directory.CreateDirectory(Path.GetDirectoryName(_secretPath)!);
await File.WriteAllTextAsync(_secretPath, Base64UrlEncode(generated), cancellationToken).ConfigureAwait(false);
RestrictToOwner(_secretPath);
_secretKey = generated;
return _secretKey;
}
finally
{
_secretGate.Release();
}
}
///
/// Keeps the HMAC key readable by the server account only. Best effort: a
/// no-op on platforms without Unix file modes.
///
private void RestrictToOwner(string path)
{
if (OperatingSystem.IsWindows())
{
return;
}
try
{
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "ShareLinks: could not restrict permissions on the token secret file.");
}
}
private static string ComputeHash(byte[] secret, ReadOnlySpan tokenBytes)
{
using var hmac = new HMACSHA256(secret);
return Base64UrlEncode(hmac.ComputeHash(tokenBytes.ToArray()));
}
private static string Base64UrlEncode(ReadOnlySpan bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static byte[] Base64UrlDecode(string value)
{
var padded = value.Replace('-', '+').Replace('_', '/');
switch (padded.Length % 4)
{
case 2:
padded += "==";
break;
case 3:
padded += "=";
break;
}
return Convert.FromBase64String(padded);
}
}