Join codes need no passkey touch and carry only public values (genesis fingerprint, owner credentials, installer hashes). Nodes pin the genesis and owners at enrollment and only follow passkey-signed operator set versions. The panel's server key is listed in the set; pinned nodes require its signature and run the control link under AES-256-GCM. Password sessions carry no capabilities. The updater adopts a next_key from a signed manifest. Bump version to 0.2.0. [CI SKIP]
136 lines
4.5 KiB
C#
136 lines
4.5 KiB
C#
namespace MoShiro.Backend.Application;
|
|
|
|
/// <summary>
|
|
/// 提权窗口的种类(docs/access.md 第 4 节):加节点(15 分钟)、吊销和删除(5 分钟)、改凭证(60 秒,用一次就没有)。
|
|
/// 窗口属于会话,不属于用户:换一台设备、一个浏览器就要重新触碰 passkey。
|
|
/// </summary>
|
|
public enum ElevationClass
|
|
{
|
|
Enroll,
|
|
Revoke,
|
|
Credential,
|
|
}
|
|
|
|
public sealed record ElevationWindow(ElevationClass Class, long ExpiresAtMs);
|
|
|
|
public static class Elevation
|
|
{
|
|
public static string IdOf(ElevationClass elevation) => elevation switch
|
|
{
|
|
ElevationClass.Enroll => "enroll",
|
|
ElevationClass.Revoke => "revoke",
|
|
_ => "credential",
|
|
};
|
|
|
|
public static bool TryParse(string? id, out ElevationClass elevation)
|
|
{
|
|
(bool ok, elevation) = id switch
|
|
{
|
|
"enroll" => (true, ElevationClass.Enroll),
|
|
"revoke" => (true, ElevationClass.Revoke),
|
|
"credential" => (true, ElevationClass.Credential),
|
|
_ => (false, default),
|
|
};
|
|
return ok;
|
|
}
|
|
|
|
/// <summary>端点要求的能力需要哪一种窗口;不是信任类的能力返回 null。</summary>
|
|
public static ElevationClass? For(string capability) => capability switch
|
|
{
|
|
Capability.FleetRevoke => ElevationClass.Revoke,
|
|
Capability.AccountsManage => ElevationClass.Credential,
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 会话的提权窗口,只在内存里:重启之后需要重新触碰 passkey,这正是想要的。API key 不经过这里(它自己带着能力,创建时已经签过)。
|
|
/// </summary>
|
|
public sealed class ElevationService
|
|
{
|
|
private readonly BackendOptions _options;
|
|
private readonly TimeProvider _time;
|
|
private readonly object _gate = new();
|
|
private readonly Dictionary<string, Dictionary<ElevationClass, long>> _windows = [];
|
|
|
|
public ElevationService(BackendOptions options, TimeProvider time)
|
|
{
|
|
_options = options;
|
|
_time = time;
|
|
}
|
|
|
|
public TimeSpan LifetimeOf(ElevationClass elevation) => elevation switch
|
|
{
|
|
ElevationClass.Enroll => TimeSpan.FromMinutes(_options.ElevationEnrollMinutes),
|
|
ElevationClass.Revoke => TimeSpan.FromMinutes(_options.ElevationRevokeMinutes),
|
|
_ => TimeSpan.FromSeconds(_options.ElevationCredentialSeconds),
|
|
};
|
|
|
|
/// <summary>开窗口;已经有的窗口重新计时。返回到期时间。</summary>
|
|
public long Grant(string sessionId, ElevationClass elevation)
|
|
{
|
|
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
|
long expires = now + (long)LifetimeOf(elevation).TotalMilliseconds;
|
|
lock (_gate)
|
|
{
|
|
Prune(now);
|
|
if (!_windows.TryGetValue(sessionId, out Dictionary<ElevationClass, long>? own))
|
|
{
|
|
_windows[sessionId] = own = [];
|
|
}
|
|
|
|
own[elevation] = expires;
|
|
}
|
|
|
|
return expires;
|
|
}
|
|
|
|
/// <summary>窗口是否有效。<paramref name="consume"/> 且是凭证类:同时用掉(一次有效)。</summary>
|
|
public bool Check(string sessionId, ElevationClass elevation, bool consume)
|
|
{
|
|
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
|
lock (_gate)
|
|
{
|
|
if (!_windows.TryGetValue(sessionId, out Dictionary<ElevationClass, long>? own) || !own.TryGetValue(elevation, out long expires) || expires <= now)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (consume && elevation == ElevationClass.Credential)
|
|
{
|
|
own.Remove(elevation);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
public IReadOnlyList<ElevationWindow> Active(string sessionId)
|
|
{
|
|
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
|
lock (_gate)
|
|
{
|
|
return _windows.TryGetValue(sessionId, out Dictionary<ElevationClass, long>? own)
|
|
? [.. own.Where(w => w.Value > now).OrderBy(w => w.Key).Select(w => new ElevationWindow(w.Key, w.Value))]
|
|
: [];
|
|
}
|
|
}
|
|
|
|
/// <summary>主动结束全部窗口(点「结束」或退出登录)。</summary>
|
|
public void End(string sessionId)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
_windows.Remove(sessionId);
|
|
}
|
|
}
|
|
|
|
private void Prune(long now)
|
|
{
|
|
foreach (string session in _windows.Where(p => p.Value.All(w => w.Value <= now)).Select(p => p.Key).ToList())
|
|
{
|
|
_windows.Remove(session);
|
|
}
|
|
}
|
|
}
|