Fichiers
jellyfin-plugin-sharelinks/Jellyfin.Plugin.ShareLinks/Services/ShareTokenService.cs
T
Franciskid 5348b4e78b harden redemption, expiry limits and token storage
Findings from a pass over the plugin, smallest first:

Redemptions now run one at a time behind a gate. The status checks and the status
write that follows them were not atomic, so two requests arriving together with
the same one-use token could both mint a guest session. The spent-link check also
moved above the tagging step, so hammering an already-used link no longer re-tags
a whole series on every hit.

The configured maximum expiry is actually respected. Both the API and the picker
did Math.max(configured, 720), so setting the ceiling to anything under 30 days
was silently ignored. The picker now also hides the quick-pick durations that sit
above the ceiling.

The share URL, which carries the raw token, is dropped from the record when the
link is revoked or expires. Records are never deleted, so dead tokens were
accumulating in the store forever. Live links keep it so the dashboard can still
copy them, and the README claim that no token is ever written to disk is corrected
to say what the code actually does.

The HMAC key file is created 0600 instead of inheriting the default mask.
2026-07-26 16:22:41 +02:00

188 lignes
5.7 KiB
C#

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;
/// <summary>
/// Generates raw share tokens and their persisted HMAC hashes.
/// </summary>
public sealed class ShareTokenService
{
private readonly string _secretPath;
private readonly ILogger<ShareTokenService> _logger;
private readonly SemaphoreSlim _secretGate = new(1, 1);
private byte[]? _secretKey;
/// <summary>Initializes a new instance of the <see cref="ShareTokenService"/> class.</summary>
public ShareTokenService(IApplicationPaths applicationPaths, ILogger<ShareTokenService> logger)
{
_secretPath = Path.Combine(applicationPaths.DataPath, "sharelinks", "token-secret.key");
_logger = logger;
}
/// <summary>Creates a new 256-bit token and its HMAC hash.</summary>
public async Task<ShareTokenMaterial> 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
};
}
/// <summary>
/// Computes the stored hash for a presented token, or <see langword="null"/> if the token
/// is missing or not well-formed base64url (treated as "no match" rather than an error).
/// </summary>
public async Task<string?> 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);
}
/// <summary>Validates a token against an expected hash.</summary>
public async Task<bool> 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<byte[]> 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)
{
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();
}
}
/// <summary>
/// Keeps the HMAC key readable by the server account only. Best effort: a
/// no-op on platforms without Unix file modes.
/// </summary>
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<byte> tokenBytes)
{
using var hmac = new HMACSHA256(secret);
return Base64UrlEncode(hmac.ComputeHash(tokenBytes.ToArray()));
}
private static string Base64UrlEncode(ReadOnlySpan<byte> 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);
}
}