Hlavicky X-MS365-* jako obycejne hodnoty, GET /users/{user_id}/calendar

- credentials: sifrovani AES-GCM/HKDF odstraneno, tri hlavicky se berou
  tak, jak jsou; bez hlavicek se pouziji hodnoty z prostredi
- zrusena promenna MS365_CREDENTIAL_ENCODING_SECRET a zavislost cryptography
- novy endpoint GET /users/{user_id}/calendar (objekt kalendare schranky,
  id a name) pro read-only overeni pristupu pres e-mail schranky
- C# ukazka posila hodnoty primo, CredentialEncoder smazan
- README: sekce o hlavickach vcetne PowerShell ukazky, zaznam zmen
- .gitignore: bin/ a obj/, zaverzovane obj/ soubory ukazky odstraneny z gitu

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-09-22 12:49:25 +02:00
co-authored by Claude Fable 5.1
parent fe3c3a736a
commit d80193da8b
20 changed files with 61 additions and 560 deletions
+8 -62
View File
@@ -3,29 +3,22 @@
// Sluzba: https://services.csbot.cz/apps/microsoft-365-service
// Dokumentace (Swagger): https://services.csbot.cz/apps/microsoft-365-service/docs
//
// Credentials se posilaji v hlavickach X-MS365-*. Kazda hodnota je zasifrovana
// AES-256-GCM klicem odvozenym pres HKDF-SHA256 ze sdileneho secretu
// (MS365_CREDENTIAL_ENCODING_SECRET na strane sluzby). Format hodnoty:
// v1.<base64url nonce>.<base64url ciphertext+tag>
// Credentials se posilaji v hlavickach X-MS365-* jako obycejne hodnoty.
//
// Spusteni:
// set MS365_SERVICE_BASE_URL=https://services.csbot.cz/apps/microsoft-365-service
// set MS365_TENANT_ID=<tenant id>
// set MS365_CLIENT_ID=<client id>
// set MS365_CLIENT_SECRET=<client secret>
// set MS365_CREDENTIAL_ENCODING_SECRET=<sdileny secret>
// set MS365_TENANT_ID=<tenant id klienta>
// set MS365_CLIENT_ID=<client id aplikace CSBOT>
// set MS365_CLIENT_SECRET=<client secret VALUE, ne secret id>
// dotnet run
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
var baseUrl = Env("MS365_SERVICE_BASE_URL", "https://services.csbot.cz/apps/microsoft-365-service");
var tenantId = Env("MS365_TENANT_ID");
var clientId = Env("MS365_CLIENT_ID");
var clientSecret = Env("MS365_CLIENT_SECRET");
var sharedSecret = Env("MS365_CREDENTIAL_ENCODING_SECRET");
const int top = 10;
@@ -33,17 +26,16 @@ using var http = new HttpClient { BaseAddress = new Uri(baseUrl.TrimEnd('/') + "
http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var request = new HttpRequestMessage(HttpMethod.Get, $"users?top={top}");
request.Headers.Add("X-MS365-Tenant-Id", CredentialEncoder.Encode(tenantId, "X-MS365-Tenant-Id", sharedSecret));
request.Headers.Add("X-MS365-Client-Id", CredentialEncoder.Encode(clientId, "X-MS365-Client-Id", sharedSecret));
request.Headers.Add("X-MS365-Client-Secret", CredentialEncoder.Encode(clientSecret, "X-MS365-Client-Secret", sharedSecret));
request.Headers.Add("X-MS365-Credential-Version", "v1");
request.Headers.Add("X-MS365-Tenant-Id", tenantId);
request.Headers.Add("X-MS365-Client-Id", clientId);
request.Headers.Add("X-MS365-Client-Secret", clientSecret);
using var response = await http.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
// 400 = spatne/prosle hlavicky, 502 = chyba z Microsoft Graph (detail je v tele),
// 400 = neuplne hlavicky, 502 = chyba z Microsoft Graph (detail je v tele),
// 403 text/plain = request neprosel pres reverzni proxy (IP allowlist pro GET).
Console.Error.WriteLine($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
Console.Error.WriteLine(body);
@@ -80,49 +72,3 @@ static string Env(string name, string? fallback = null)
}
throw new InvalidOperationException($"Chybi environment promenna {name}.");
}
/// <summary>
/// Sifrovani hodnot pro hlavicky X-MS365-*. Musi presne odpovidat dekodovani na strane sluzby
/// (app/credentials.py): HKDF-SHA256 se salt "microsoft-365-service.credentials.v1"
/// a info = nazev hlavicky, AES-256-GCM s 12B nonce, 16B tagem a AAD = nazev hlavicky.
/// </summary>
static class CredentialEncoder
{
private static readonly byte[] HkdfSalt = Encoding.UTF8.GetBytes("microsoft-365-service.credentials.v1");
public static string Encode(string value, string headerName, string sharedSecret, TimeSpan? validity = null)
{
var now = DateTimeOffset.UtcNow;
var payload = JsonSerializer.Serialize(new
{
value,
issued_at = now.ToString("O"),
expires_at = now.Add(validity ?? TimeSpan.FromHours(1)).ToString("O"),
});
var headerBytes = Encoding.UTF8.GetBytes(headerName);
var key = HKDF.DeriveKey(
HashAlgorithmName.SHA256,
Encoding.UTF8.GetBytes(sharedSecret),
outputLength: 32,
salt: HkdfSalt,
info: headerBytes);
var nonce = RandomNumberGenerator.GetBytes(12);
var plaintext = Encoding.UTF8.GetBytes(payload);
var ciphertext = new byte[plaintext.Length];
var tag = new byte[16];
using var aes = new AesGcm(key, tagSizeInBytes: 16);
aes.Encrypt(nonce, plaintext, ciphertext, tag, associatedData: headerBytes);
var ciphertextAndTag = new byte[ciphertext.Length + tag.Length];
ciphertext.CopyTo(ciphertextAndTag, 0);
tag.CopyTo(ciphertextAndTag, ciphertext.Length);
return $"v1.{Base64Url(nonce)}.{Base64Url(ciphertextAndTag)}";
}
private static string Base64Url(byte[] data) =>
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}