Ozakboy.Security 0.1.1

dotnet add package Ozakboy.Security --version 0.1.1
                    
NuGet\Install-Package Ozakboy.Security -Version 0.1.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Ozakboy.Security" Version="0.1.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Ozakboy.Security" Version="0.1.1" />
                    
Directory.Packages.props
<PackageReference Include="Ozakboy.Security" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Ozakboy.Security --version 0.1.1
                    
#r "nuget: Ozakboy.Security, 0.1.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Ozakboy.Security@0.1.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Ozakboy.Security&version=0.1.1
                    
Install as a Cake Addin
#tool nuget:?package=Ozakboy.Security&version=0.1.1
                    
Install as a Cake Tool

Ozakboy.Security

Credential and sensitive-data protection for .NET 10. Three things, nothing more:

  1. DPAPI secret protection — encrypt credentials with Windows DPAPI so they never sit on disk in plain text, behind an interface you can replace.
  2. Log masking — mask secrets before they reach a log: plain strings, URL query parameters, JSON fields, and free-form text where the caller never gets to hand the value over.
  3. Configuration encryption — AES-GCM encryption for a configuration file or a section of it, plus PBKDF2 key derivation.

繁體中文說明請見 README_zh-TW.md

Design notes

  • No third-party dependencies. Everything is built on the BCL. The single package reference, System.Security.Cryptography.ProtectedData, is published by Microsoft and is the only way to reach DPAPI from .NET.
  • Failures are classified, not guessed. Both payload formats carry a magic header and a version byte, so a corrupted value, an unknown format version and a scope mismatch are told apart before decryption is attempted. Whatever is left is reported as DecryptionFailed.
  • Expected failures do not throw. A configuration file copied from another machine, edited by hand, or encrypted under a rotated key is normal. Every restore path has a Try… counterpart that returns false.
  • No built-in key, no hard-coded salt. Where key material comes from is the caller's decision.

Install

dotnet add package Ozakboy.Security

Target framework: net10.0.

1. DPAPI secret protection

using Ozakboy.Security.Protection;

ISecretProtector protector = new DpapiSecretProtector();

// Encrypt once, then write the result into your configuration file.
string stored = protector.Protect("my-api-key-value");
Console.WriteLine(stored);            // Base64, starts with the OZDP envelope

// Read it back. TryUnprotect never throws for bad data.
if (protector.TryUnprotect(stored, out string? apiKey))
{
    Console.WriteLine(apiKey);        // my-api-key-value
}
else
{
    Console.WriteLine("Cannot restore this value on this machine or user account.");
}

Scope and additional entropy:

using Ozakboy.Security.Protection;

var options = new DpapiProtectionOptions
{
    // CurrentUser (default): only the account that encrypted it can read it back.
    // LocalMachine: any account on this machine can, which is what a Windows service usually needs.
    Scope = SecretProtectionScope.LocalMachine,
}.WithEntropyText("MyApp/credentials/v1");

var protector = new DpapiSecretProtector(options);
string stored = protector.Protect("my-api-key-value");

What the additional entropy actually buys you

Be precise about this one, because it is easy to over-read.

  • It protects against other programs that end up holding the file but do not know the entropy: a generic DPAPI decryption tool, or whoever restores a backup and starts opening files. Without the entropy they get nothing, even running under the same account.
  • It does not protect against code running as the same user. Passing an application constant — as in the snippet above — means the entropy is compiled into the assembly, and anything able to run as that user can decompile it straight back out. At that point DPAPI is stopping nothing extra.
  • If you need to stop same-account code, the entropy has to come from a passphrase the operator types in at start-up and it must never be written to disk. The price is unattended start-up: with nobody there to type, the service does not come up.

When you need the reason a value could not be restored, use Unprotect and catch:

using Ozakboy.Security;
using Ozakboy.Security.Protection;

try
{
    string apiKey = new DpapiSecretProtector().Unprotect(stored);
}
catch (SecretProtectionException ex) when (ex.Reason == SecretProtectionFailureReason.ScopeMismatch)
{
    // Encrypted with LocalMachine, being read with CurrentUser (or the other way round).
}
catch (SecretProtectionException ex) when (ex.Reason == SecretProtectionFailureReason.MalformedPayload)
{
    // The configuration file was truncated or hand-edited.
}
catch (SecretProtectionException)
{
    // DecryptionFailed: tampered data, or protected by another user account or machine.
}

DPAPI is a Windows facility. On any other platform IsSupported is false and the protect/unprotect calls throw PlatformNotSupportedException with an explicit message. That is exactly why ISecretProtector exists: inject a different implementation (environment variables, an OS keychain, a cloud KMS) and the rest of your code does not change.

2. Log masking

using Ozakboy.Security.Masking;

SecretMasker masker = SecretMasker.Default;

Console.WriteLine(masker.Mask("abcdefghijklmnopqrstuvwxyz"));
// abcd****wxyz

Console.WriteLine(masker.Mask("short"));
// **** — values too short to keep both ends visible are masked entirely

Console.WriteLine(masker.MaskQueryString("/api/v3/order?symbol=BTCUSDT&apiKey=abcdefghijklmnopqrst"));
// /api/v3/order?symbol=BTCUSDT&apiKey=abc****rst

Console.WriteLine(masker.MaskJson("""{"apiKey":"abcdefghijklmnopqrst","symbol":"BTCUSDT","quantity":1.5}"""));
// {"apiKey":"abc****rst","symbol":"BTCUSDT","quantity":1.5}

The endpoint and the harmless parameters survive on purpose: a log that hides which call was made is not much use when you are debugging.

How much is ever revealed

Two limits apply, and the second one cannot be configured away:

  1. The configured prefix and suffix lengths (4 and 4 by default), and a minimum number of hidden characters (4 by default, never below 1) before either end is shown at all.
  2. At most one third of the value is ever revealed. Whatever the options say, a 12-character value shows 2 + 2, not 4 + 4; a 64-character exchange API key shows the configured 4 + 4, which is well under the cap. This exists so a loose configuration cannot produce output that looks masked while reproducing the original.

Password-like fields — password, passwd, pwd, passphrase, mnemonic, privateKey and their variants — are always masked in full, with no characters at either end. Human passwords carry far too little entropy for the keep-both-ends rule to be safe on them.

Which field names count as sensitive

Names are matched case-insensitively, in two passes:

  • Exact match against the built-in list, which covers apiKey, api_key, x-api-key, x-mbx-apikey (the header Binance uses for API keys), secret, secretKey, signature, token, bearer, jwt, otp, mnemonic, seed, sessionId, webhookSecret, privateKey, password, authorization and several dozen more.
  • Substring match — enabled by default (UseSubstringMatching) — against fragments such as key, secret, token, password, credential, passphrase, signature, session and cookie. This is what catches binanceApiKey, api_key_1 and Api-Key-Secret, none of which an exact-match list would ever see coming.

Substring matching over-masks by design: a field named keyword or publicKey gets masked too. For a log masker that is the right trade — an unreadable field costs less than a leaked key — but you can turn it off with UseSubstringMatching = false, or supply your own fragments.

Query-parameter names are percent-decoded before they are matched, so ?api%4Bey=… (%4B is K) does not slip through; the original spelling is written back out. The JSON path decodes names the same way, so both entry points match on the same basis.

Adjust how much stays visible, or extend the lists:

using Ozakboy.Security.Masking;

var masker = new SecretMasker(new SecretMaskOptions
{
    VisiblePrefixLength = 2,
    VisibleSuffixLength = 2,
    MaskLength = 6,
    MaskCharacter = '#',
    AdditionalSensitiveNames = ["listenKey"],
    AdditionalSensitiveNameFragments = ["venue"],
    AdditionalFullMaskNames = ["withdrawWhitelistAddress"],
});

Console.WriteLine(masker.Mask("abcdefghijklmnop"));              // ab######op
Console.WriteLine(masker.MaskNamedValue("symbol", "BTCUSDT"));   // BTCUSDT (not sensitive, untouched)

The mask segment has a fixed length, so the output never reveals how long the original value was. On a logging path use TryMaskJson, which returns false for invalid JSON instead of throwing:

if (SecretMasker.Default.TryMaskJson(responseBody, out string? safeToLog))
{
    logger.LogInformation("response: {Body}", safeToLog);
}

Never write the fallback. try { log(MaskJson(x)) } catch { log(x) } throws away the whole fail-closed design: the moment masking fails is usually the moment the content least belongs in a log. If TryMaskJson returns false, log the fact that it failed — not the payload.

The values you never got to hand over

The three calls above all need the caller to produce the value. In practice that is not where credentials leak:

logger.LogError("order failed key={Key}", apiKey);        // nothing above can see this
catch (Exception ex) { logger.LogError(ex.ToString()); }  // nor a key embedded in exception text

Register the secret once at start-up, and run free-form text through MaskText:

using Ozakboy.Security.Masking;

// At start-up, right after the credentials are decrypted.
SecretMasker.Default.RegisterKnownSecret(apiKey);
SecretMasker.Default.RegisterKnownSecret(apiSecret);

// Anywhere text is about to be logged.
logger.LogError("order failed: {Message}", SecretMasker.Default.MaskText(ex.ToString()));
  • Values shorter than SecretMasker.MinimumKnownSecretLength (8 characters) are rejected: registering a short string would mask swathes of ordinary log text. The rejection message never echoes the value.
  • Matching is ordinal and case-sensitive, because credentials are. The one exception is hexadecimal values (signatures, digests), which are registered in both casings since libraries disagree about which to emit.
  • Registration is thread-safe and lock-free on the reading side: MaskText reads an immutable snapshot, so it is safe to call from every thread of a trading loop. With nothing registered it returns the input string as-is and allocates nothing.
  • MaskJson and MaskQueryString also run registered secrets over their output, so a key smuggled inside an innocently named field is still caught.

MaskText is a net under the other three, not a replacement for them: it only knows the values you registered.

3. Configuration encryption

using System.Security.Cryptography;
using Ozakboy.Security.Configuration;

// The salt is stored next to the ciphertext and is not a secret. It is at least 16 bytes (128 bits).
byte[] salt = KeyDerivation.CreateSalt();
byte[] key = KeyDerivation.DeriveKey("the user's passphrase", salt);
try
{
    string encrypted = ConfigurationProtector.Encrypt(File.ReadAllText("appsettings.json"), key);
    File.WriteAllText("appsettings.protected", encrypted);
    File.WriteAllText("appsettings.salt", Convert.ToBase64String(salt));

    if (ConfigurationProtector.TryDecrypt(encrypted, key, out string? json))
    {
        Console.WriteLine(json);
    }
}
finally
{
    // Derived keys are ordinary byte arrays: zero them as soon as you are done, do not wait for the GC.
    CryptographicOperations.ZeroMemory(key);
}

A password that arrives as a string can never be scrubbed — strings are immutable, and the GC leaves copies behind as it moves them, which is how passwords reach memory dumps and page files. When you control where the password comes from, use the byte overload and clear the buffer yourself:

using System.Security.Cryptography;
using System.Text;
using Ozakboy.Security.Configuration;

byte[] password = Encoding.UTF8.GetBytes(ReadPassphraseFromOperator());
byte[] salt = KeyDerivation.CreateSalt();
byte[] key = KeyDerivation.DeriveKey(password, salt);   // identical result to the string overload
try
{
    string encrypted = ConfigurationProtector.Encrypt("""{"apiKey":"…"}""", key);
}
finally
{
    CryptographicOperations.ZeroMemory(password);
    CryptographicOperations.ZeroMemory(key);
}

Or generate a random key and keep the key itself under DPAPI:

using System.Security.Cryptography;
using Ozakboy.Security.Configuration;
using Ozakboy.Security.Protection;

byte[] key = KeyDerivation.CreateKey();                 // 32 bytes, AES-256
var protector = new DpapiSecretProtector();
try
{
    // What lands in the configuration file is the DPAPI-protected key, never the key itself.
    string storedKey = protector.Protect(Convert.ToBase64String(key));
    string encrypted = ConfigurationProtector.Encrypt("""{"apiKey":"…"}""", key);
}
finally
{
    CryptographicOperations.ZeroMemory(key);
}

A fresh nonce is generated for every encryption, so encrypting the same content twice never produces the same ciphertext. AES-GCM authenticates as well as encrypts: change one byte of the payload and decryption fails instead of quietly returning something wrong. The nonce is 96 random bits, so by the birthday bound a single key should not encrypt more than 2^32 times (about 4.3 billion) before it is rotated — configuration encryption sits nowhere near that volume.

ConfigurationProtector.IsProtectedValue(value) tells an already-encrypted configuration value apart from one still in plain text, without needing the key — handy when migrating an existing file. ConfigurationProtector.IsSupported reports whether the platform offers AES-GCM at all; check it at start-up rather than discovering the answer the first time you write a configuration file.

Payload formats

Magic Layout
DpapiSecretProtector OZDP magic (4) + version (1) + scope (1) + DPAPI blob
ConfigurationProtector OZCF magic (4) + version (1) + nonce (12) + tag (16) + ciphertext

Both are Base64-encoded in their string form. The OZCF header is also fed to AES-GCM as associated data, which binds header and ciphertext together under the authentication tag. Note what actually happens when you tamper with the header today: with only format version 1 in existence, the format check rejects a bad magic or version before AES-GCM is reached, so that particular rejection comes from the format check, not from the tag. The associated data is what keeps the binding sound once more than one format version is in circulation — and the test suite proves it is really enforced by decrypting an envelope whose header is a valid v1 but whose ciphertext was authenticated under different associated data.

Failure reasons

SecretProtectionException.Reason is one of:

Reason Meaning
MalformedPayload Not valid Base64, too short, or missing the format header.
UnsupportedFormatVersion Written by a newer version of this library.
ScopeMismatch Protected under LocalMachine but read as CurrentUser, or the reverse.
DecryptionFailed Tampered data, a wrong key, or another user account or machine.
PlatformNotSupported The mechanism is unavailable on this platform. DpapiSecretProtector throws PlatformNotSupportedException for this case; the value is here for substitute implementations that would rather report it as a failure reason.

Requirements

  • .NET 10
  • Windows for the DPAPI parts; masking, AES-GCM and PBKDF2 run anywhere.

License

MIT. See LICENSE.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (2)

Showing the top 2 NuGet packages that depend on Ozakboy.Security:

Package Downloads
Ozakboy.TradeKit.Binance

Ozakboy.TradeKit.Abstractions 的幣安 USDⓈ-M 永續合約實作,建構在 Ozakboy.Http 的簽章/限流/重試管線之上,不使用任何第三方社群套件。本版提供交易規則(exchangeInfo 的 PRICE_FILTER / LOT_SIZE / MIN_NOTIONAL / MARKET_LOT_SIZE)對映與每日快照快取、伺服器時間、帳戶與持倉查詢,以及下單、撤單、查單與槓桿/保證金模式設定(下單一律不可重試,並以 clientOrderId 作為冪等識別碼),再加上幣安錯誤碼到交易所中立錯誤碼的完整對映(時間戳偏移、簽章無效、IP 白名單、保證金不足、限流各自可辨識)。REST 與 WebSocket 端點以「環境」成組提供,杜絕主網與 Testnet 混接;交易規則快取以環境為鍵,避免 Testnet 的步進值被誤用到主網。行情方面提供歷史 K 線與盤口(買一賣一)查詢,以及 WebSocket 的 K 線、標記價與盤口訂閱(連線管理、重連與重連後重放訂閱取自 Ozakboy.WebSockets);K 線的「是否已收盤」逐筆正確對映,REST 回應沒有這個旗標的部分則以收盤時間推得,策略不會拿到未收盤的 K 線當成收盤資料。條件單(停損、停利、移動停損)走幣安 2025-12-09 起啟用的 Algo Order 端點,與一般委託分成兩條路徑:送單同樣絕不重試,clientAlgoId 失敗時也帶得回來,狀態變化由使用者資料串流的 ALGO_UPDATE 事件送出,而一般的掛單查詢、委託串流與撤銷全部掛單都看不到條件單,緊急出場必須兩邊都撤。使用者資料串流提供委託、條件單、成交、帳戶增量、保證金追繳與對帳訊號六種訂閱:單一連線內部分流,listenKey 自動建立/續期/重建/刪除,以 LIST_SUBSCRIPTIONS 心跳做閒置偵測;跟不上的訂閱者以失敗結束而不靜默丟棄事件,串流憑證不進任何錯誤與日誌,並在取得當下登記成遮罩器的已知祕密,連本套件管不到的路徑流出的那一份也會被換成遮罩字串。憑證的續期成功、續期失敗與失效都有日誌與計數(日誌一律不含憑證),次數與時刻另以 ListenKeyStatus 快照公開給健康度呈現,續期失敗會以短退避重試而不是空等下一個排程。A Binance USDⓈ-M perpetual futures implementation of Ozakboy.TradeKit.Abstractions, built on the Ozakboy.Http signing, rate-limiting, and retry pipeline with no third-party dependencies. This release covers exchange-info trading rules with a per-environment daily cache, server time, account and position queries, order placement, cancellation and lookup, leverage and margin mode, and a full Binance-to-neutral error code mapping. Order placement is never retried and carries a clientOrderId as its idempotency key. Market data covers historical klines and book ticker snapshots plus WebSocket kline, mark price, and book ticker subscriptions, with the candle closed flag mapped from the stream and derived from the close time on REST, never assumed. Conditional orders (stop, take-profit, trailing) use the Algo Order endpoints Binance switched to on 2025-12-09 and form a path of their own: placement is never retried, the clientAlgoId comes back even on failure, state changes arrive on the user data stream as ALGO_UPDATE, and the ordinary open-orders query, order stream, and cancel-all never see them, so an emergency exit has to clear both sides. The user data stream covers order updates, conditional order updates, fills, account deltas, margin calls, and resync signals over one fanned-out connection, with a fully managed listenKey and heartbeat-based idle detection; a subscriber that falls behind ends with a failure instead of silently losing events, and the stream credential never reaches an error or a log and is registered as a known secret on the client masker the moment it is obtained, so even a copy leaving by a route this package does not control comes out masked. Credential renewals, their failures, and an expiry are logged and counted without the credential ever appearing in a line, the counts and instants are exposed as a ListenKeyStatus snapshot for health display, and a failed renewal is retried after a short backoff rather than waiting out the next scheduled attempt.

Ozakboy.Http

建構在官方 HttpClientFactory 與 DelegatingHandler 之上的 HTTP 管線,取代 RestSharp / Flurl / Polly 這類第三方函式庫:順序固定的簽章、加權多桶限流、冪等感知重試與脫敏日誌四段處理器。簽章以有序參數容器杜絕字典列舉順序造成的隨機失敗,限流建於官方 System.Threading.RateLimiting 之上並以 TimeProvider 驅動,重試僅對安全方法或明確標記為冪等的請求生效並尊重 Retry-After。所有預期失敗以 Result<T> 表達而非例外。除 Microsoft 官方套件與 Ozakboy.* 自研套件外,零第三方相依。A signing, rate-limiting, retry, and log-masking DelegatingHandler pipeline built on the official HttpClientFactory, replacing third-party libraries such as RestSharp, Flurl, and Polly. Ordered query containers keep signatures deterministic, rate limiting is driven by TimeProvider for testability, retries apply only to safe or explicitly idempotent requests, and expected failures are returned as Result<T> rather than thrown.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.1 250 9/11/2026
0.1.0 82 9/11/2026