Epsitec.Briefcases.Identity
6.1.0.2638
Prefix Reserved
dotnet add package Epsitec.Briefcases.Identity --version 6.1.0.2638
NuGet\Install-Package Epsitec.Briefcases.Identity -Version 6.1.0.2638
<PackageReference Include="Epsitec.Briefcases.Identity" Version="6.1.0.2638" />
<PackageVersion Include="Epsitec.Briefcases.Identity" Version="6.1.0.2638" />
<PackageReference Include="Epsitec.Briefcases.Identity" />
paket add Epsitec.Briefcases.Identity --version 6.1.0.2638
#r "nuget: Epsitec.Briefcases.Identity, 6.1.0.2638"
#:package Epsitec.Briefcases.Identity@6.1.0.2638
#addin nuget:?package=Epsitec.Briefcases.Identity&version=6.1.0.2638
#tool nuget:?package=Epsitec.Briefcases.Identity&version=6.1.0.2638
Epsitec.Briefcases.Identity
User identity lifecycle for Crésus Briefcases: bring a user from "nothing on
this device" to a live BriefcasesSession without re-implementing key
derivation, at-rest encryption, token acquisition, and keyset registration by
hand. This is the single briefcases library that depends on Epsitec.Rezo.Api;
the SDK stays identity-agnostic and merely consumes the
BriefcasesSessionOptions this package produces.
BriefcasesIdentity owns auth and keyset concerns and produces
BriefcasesSessionOptions; it never opens a BriefcasesSession itself. The host
constructs the session from the returned options, so the dependency direction
stays host → Identity → Sdk.
Usage — the shortest happy path
For a host that prefers one call, OpenAsync runs the whole tree (unlock,
recover, or provision) and returns ready session options. It asks the host for
input through IdentityPrompts.
var identity = new BriefcasesIdentity (new BriefcasesIdentityOptions
{
AuthUrl = new Uri ("https://auth.api.cresus.ch"),
BriefcasesServerUrl = new Uri ("https://briefcases.example.com"),
KeyStore = new FileKeyStore (keyDirectory),
});
var options = await identity.OpenAsync (new IdentityPrompts
{
Login = ct => ui.AskEmailAsync (ct), // auth login (the email)
AuthPassword = ct => ui.AskAuthPasswordAsync (ct), // sent to auth
Totp = ct => ui.AskTotpAsync (ct),
AtRestPassword = ct => ui.AskAtRestPasswordAsync (ct),// guards the local keys
EnterMnemonic = ct => ui.AskMnemonicAsync (ct),
ShowMnemonic = (mnemonic, ct) => ui.ShowMnemonicAsync (mnemonic, ct),
});
await using var session = new BriefcasesSession (options);
await session.ConnectAsync ();
OpenAsync authenticates first — the briefcases uid is the access token's sub
claim, so it is unknown until the login (email + auth password) succeeds — then
derives the uid, steps up MFA when the branch needs a keyset write, and
dispatches to unlock, recover, or provision per GetStatusAsync. The local keys
are always (un)locked with the at-rest password, never the auth password.
Two distinct secrets. The auth password is the account password sent to
authwith the email login to get a token; the at-rest password is a briefcases-only secret that derives the AES key encrypting the local private keys. They are never the same parameter —IdentityPromptscollects them separately, and the unlock / recover / provision / publish operations only ever receiveatRestPassword.
First run — provision a new identity
Provisioning generates a fresh mnemonic and key set, stores the private keys at
rest, and publishes the public keyset to the auth registry. Publishing is a
keyset write, so it requires an MFA-validated token: step up first, then
provision. The new mnemonic comes back in the result — show it to the user once.
await using var auth = await identity.AuthenticateAsync (login, authPassword);
// auth.Uid is the access token's `sub` claim — the briefcases uid.
await auth.StepUpMfaAsync (totpCode); // MFA is a precondition for the write
var result = await identity.ProvisionAsync (auth, atRestPassword);
ShowToUser (result.Mnemonic); // the only way to recover on a new device
await using var session = new BriefcasesSession (result.SessionOptions);
await session.ConnectAsync ();
ProvisionAsync enforces MFA as a client-side precondition: when the
identity is not MFA-validated it throws MfaStepUpRequiredException before any
network call or key store write, so the host gets one immediate, unambiguous
signal. It probes the key store the same way, before generating anything: when
a slot this version does not read already holds the user's key material it
throws KeyStoreFormatException instead of publishing a new keyset over keys
the user still owns (see Slots this version does not read).
Returning user on a known device — unlock
The returning user authenticates once (to get a fresh token and the uid from the
token sub), then unlocks the local keys with the at-rest password — the registry
is never contacted. The decrypted keys plus a refreshing token provider come back
as session options.
await using var auth = await identity.AuthenticateAsync (login, authPassword);
if (await identity.HasLocalKeysAsync (auth.Uid))
{
var options = await identity.UnlockAsync (auth, atRestPassword);
await using var session = new BriefcasesSession (options);
await session.ConnectAsync ();
}
HasLocalKeysAsync is the cheap, network-free check; it delegates to
IKeyStore.ContainsAsync. UnlockAsync does not re-authenticate — it takes the
already-authenticated auth — and uses only the at-rest password to decrypt the
private keys. Both throw KeyStoreFormatException when a slot file exists but
this version does not read it: that is a refusal, never a false or a null,
so a host must not treat it as "no keys" and provision (see Choosing a key
store).
New device — BIP-39 recovery
On a device with no local keys, recover from the BIP-39 mnemonic. Recovery
re-derives the keys from (uid, mnemonic) and verifies they match the
published keyset before trusting them, so a wrong mnemonic fails loudly instead
of silently creating a divergent identity.
await using var auth = await identity.AuthenticateAsync (login, authPassword);
try
{
var options = await identity.RecoverAsync (auth, mnemonic, atRestPassword);
await using var session = new BriefcasesSession (options);
await session.ConnectAsync ();
}
catch (IdentityMismatchException)
{
// Wrong mnemonic (or a keyset for another subject): ask the user to retry.
}
Recovery writes no keyset, so it needs no MFA.
Login then MFA step-up
AuthenticateAsync takes the auth login (the email) and the auth password and
returns a password-tier AuthenticatedIdentity whose IsMfaValidated is false
and whose Uid is the access token's sub claim. StepUpMfaAsync elevates it
with a TOTP code, reusing the refresh token so the auth password is not re-entered.
Both IsMfaValidated (the amr claim) and Uid (the sub claim) are read
locally from the token — no server round-trip.
await using var auth = await identity.AuthenticateAsync (login, authPassword);
while (auth.IsMfaValidated == false)
{
try
{
await auth.StepUpMfaAsync (await ui.AskTotpAsync ());
}
catch (MfaCodeInvalidException)
{
// Loop and re-prompt for a fresh code.
}
catch (MfaRateLimitedException ex)
{
await ui.ShowRateLimitAsync (ex.RetryAfter);
throw;
}
}
Every keyset write requires an MFA-validated identity. The host only ever handles
one exception type for "needs TOTP": ProvisionAsync / PublishAsync throw
MfaStepUpRequiredException as a local precondition, and a server-side MFA
rejection surfacing from a write is re-mapped to the same exception.
Discover a user's keys to invite them
Discovery reads another user's latest published keyset by uid, replacing the
manual profile.json exchange. Reads need no MFA. A keyset outside the
briefcases profile policy — ecdsa-p256-sha384, ml-dsa-65, x25519, and
ml-kem-768 — is rejected as a typed KeysetProfileMismatchException rather
than returning unusable keys.
await using var auth = await identity.AuthenticateAsync (login, authPassword);
UserPublicKeys inviteeKeys = await identity.GetPublicKeysAsync (auth, otherUid);
// Feed inviteeKeys straight into the SDK invitation flow instead of importing a
// profile.json — e.g. workspace.InviteAsync (otherUid, inviteeKeys).
Rotating the identity keys
RotateAsync rotates the user's whole identity — fresh mnemonic, fresh key
set — and carries the rotation through end to end: it stages the new keyset,
publishes it to auth (the MFA-gated write; a failed publish aborts before any
briefcase is touched), re-keys every owned briefcase (the online set is
auto-discovered, local-only ones are supplied by the caller), and promotes the
new keyset to the active local identity only once every target is fully
re-keyed. The flow is resumable and single-in-flight per uid — re-run it after
an interruption and it continues where it stopped.
await using var auth = await identity.AuthenticateAsync (login, authPassword);
await auth.StepUpMfaAsync (totpCode); // the keyset publish is MFA-gated
var result = await identity.RotateAsync (auth, atRestPassword, localOnlyTargets: []);
ShowToUser (result.NewMnemonic); // the NEW phrase; the old one is void
// result.RekeyedBriefcases / SkippedAlreadyRekeyed summarize the rekey pass.
EnumerateOwnedOnlineBriefcasesAsync exposes the auto-discovered online set so
a client can preview what a rotation will touch. On a second machine that
holds a briefcase local-only, AdoptRotationAsync adopts an already-initiated
rotation: it re-derives the new keys from the initiator's recovery phrase,
verifies the derived hkid against the expected one before any ledger
write (IdentityMismatchException otherwise), and re-keys the supplied
local-only targets — no auth write, no MFA, no new mnemonic.
Owner-side membership upkeep after a member rotates
A member who rotates leaves the briefcases they belong to keyed to their old
keys; the owner repairs that. DetectRotatedMembersAsync scans an owned
briefcase and reports the active members whose ledger keys are Superseded on
auth while a different keyset is Valid. ReconcileMembersAsync classifies
every active member and re-keys only the rotated ones; RekeyMemberAsync
targets one member and always re-wraps — the repair path for a pending card
capsule. All three resolve and validate the member's keys against auth first,
and a per-member trust condition (revoked keyset, mismatch, registry error)
lands in that member's result instead of failing the pass — with no ledger
write for that member.
var report = await identity.ReconcileMembersAsync (auth, atRestPassword, bid);
foreach (var r in report.Results)
{
// r.Status: Rekeyed / AlreadyCurrent / NoValidKeyset / RevokedKeyset /
// KeyMismatch / RegistryError; r.Outcome carries the SDK re-key outcome
// when a re-key was committed.
}
Authenticating from an existing token
AuthenticateWithTokenAsync is the token-based counterpart of
AuthenticateAsync for a caller that already holds a live access token (a CLI
sidecar session, a host with its own login flow) and no password. The uid is
derived from the token's sub claim exactly as on the password path, and no
network call is performed. It returns null — instead of throwing — when the
token is absent, unparseable, expired, or carries no usable sub claim, so the
caller falls back to interactive authentication.
The token's sub must name the base identity — the bare
uid.<base64url-guid> form. A persona-suffixed subject
(uid.<base64url-guid>.1), as minted by an application flow that selected a
persona, also yields null: briefcases never authenticates a persona. When a
token is rejected, ClassifyTokenSubject says why — it decodes the sub
locally, never throws, and tells apart the canonical form, a persona-suffixed
subject (with the persona index and the base uid), a missing subject, and
anything else:
var auth = await identity.AuthenticateWithTokenAsync (accessToken, ct);
if (auth is null)
{
var info = BriefcasesIdentity.ClassifyTokenSubject (accessToken);
if (info.Kind == TokenSubjectKind.PersonaSubject)
{
// The token names persona info.PersonaId of user info.BaseUid:
// acquire a base-persona token and retry.
}
}
Shared keyset bridge for server-side checks
BriefcasesKeysetBridge exposes the common conversion and identity checks used by both the identity flows and server-side validation code. It keeps the Epsitec.Rezo.Api dependency in this package while letting consumers work from the briefcases model types.
BriefcasesKeysetBridge.ComputeHkid(publicKeys)derives theauthhkidfrom the two signature keys inUserPublicKeys.BriefcasesKeysetBridge.ToUserPublicKeys(normalizedKeyset)maps a normalizedauthkeyset back to the four briefcases public-key slots.BriefcasesKeysetBridge.MatchesPublicKeys(normalizedKeyset, publicKeys)verifies both thehkidand the four SPKI DER public-key slots.BriefcasesKeysetProfiles.Policyis the profile policy for keysets briefcases can consume:ecdsa-p256-sha384,ml-dsa-65,x25519, andml-kem-768.
The intended server-side flow is to compute the hkid from received UserPublicKeys, read the keyset from auth by (uid, hkid), normalize it through ApiKeyset.ToNormalized(), then call MatchesPublicKeys(...). The comparison deliberately covers the encryption keys too: hkid identifies only the hybrid signing keys.
Choosing a key store
FileKeyStore writes one {uid}.keystore.json per user under a host-supplied
directory (plus the .pending and .archive.<hkid> staging slots), as UTF-8
without BOM. DpapiKeyStore keeps the same document in a Windows
DPAPI-protected file named {uid}.keystore.dpapi
([SupportedOSPlatform("windows")]).
Note: give the key store a directory of its own. In particular, do not point it at the directory the
auth-clicompanion uses for its session files: the sidecar it writes there is named{uid}.keyset.json, which is exactly the slot name this format replaced, so every read of that user's active slot would be refused as an older file.
Each slot is a self-describing camelCase document whose first two members are
"formatVersion": 1 and "uid", followed by the public keys and the
EncryptedSecret blobs, including the optional Kdf block that records the
Argon2id parameters. The block stays optional: a blob written before it existed
is read with the frozen pre-block profile and is never re-encrypted on the way
in.
Slots this version does not read
A reader accepts exactly format version 1 for exactly the requested uid.
Anything else raises KeyStoreFormatException, which carries FilePath, the
Slot it was reading (Active, Pending, Archive) and the Reason
(LegacyFile, MissingOrOlderVersion, NewerVersion, UidMismatch,
Incomplete). The refusal is never reported as "no keys": ContainsAsync,
LoadAsync, LoadPendingAsync, LoadByHkidAsync and PromotePendingAsync
throw it, and no code path writes, renames or deletes a slot it failed to read.
The writes enforce that themselves: SaveAsync and SavePendingAsync look at
the file already standing under the slot name and throw instead of replacing it
when it declares a newer version or names another owner — a host that re-seeds
the store from its own copy of the key material cannot silently destroy it.
A slot holding nothing usable (no supported version marker, an incomplete or
unparseable document) is overwritten, because the recovery flow is the
documented repair and must be able to land. Writing the current slot beside a
file bearing the name used before this format is likewise allowed: that file is
never read, never written and never deleted.
Slots written before this version are not read, and there is no conversion:
- An active slot is rebuilt with the recovery flow (
RecoverAsync, orbriefcases identity recover), which writes the current slot and leaves the older file where it is. - Finish any in-flight key rotation before upgrading. A pending slot staged by an older version is refused, a recovery does not clear it, and the tool never deletes it: the user finishes that rotation with the version that started it, or moves the file away.
- Subclasses of
KeyStoreBasegain the envelope and the refusal rules through the base class; overridingLegacyFileSuffixis what makes such a store report an older file of its own by name instead of ignoring it.
try
{
var status = await identity.GetStatusAsync (auth, ct);
// ...
}
catch (KeyStoreFormatException ex) when (ex.Slot is KeyStoreSlot.Active)
{
// Never provision here: that would publish a new keyset over keys the
// user still owns.
await PromptRecoveryAsync (ex.FilePath, ct);
}
IKeyStore store = OperatingSystem.IsWindows ()
? new DpapiKeyStore (keyDirectory)
: new FileKeyStore (keyDirectory);
The OS protection is applied in addition to the password encryption, never instead of it: the private keys stay password-encrypted at the briefcases layer, and DPAPI adds machine/user binding on top.
Handing off to the SDK
Every flow returns a BriefcasesSessionOptions that plugs straight into the SDK:
await using var session = new BriefcasesSession (options);
await session.ConnectAsync ();
The session's AccessTokenProvider is wired to the identity's refreshing token
provider, so the access token is refreshed transparently for every SignalR and
blob request — the host does not re-log-in mid-session. A QR-imported identity
feeds the same store-then-publish path through BriefcasesKeyImport.FromQrPayload,
so provisioning from a scanned pairing code reuses, rather than duplicates, the
publication flow.
Verifying workspace cards (cold-read defense in depth)
A cold card listing carries no ledger, so the SDK cannot verify the owner's
envelope signature on its own. AuthWorkspaceCardSignerKeyResolver plugs the
trusted auth directory into that gap: given a card signer's id
and the hkid the server serves with each card, it resolves the owner's public
keys from auth so the SDK verifies the signature itself.
var resolver = new AuthWorkspaceCardSignerKeyResolver (
authUrl, options.AccessTokenProvider);
await using var session = new BriefcasesSession (
options, cardSignerKeyResolver: resolver);
Each WorkspaceCardInfo then carries SignatureVerified (null = not checked,
true = valid, false = invalid). Verification is opt-in (no resolver ⇒
cards list unverified) and best-effort — resolution is cached per
(signer, hkid), fetches the keyset in any state (a card signed by a
since-rotated key still verifies), and any failure leaves the card unverified
rather than failing the listing.
What the host still provides
- UI prompts —
IdentityPrompts(the email login, the auth password, TOTP, the at-rest password, mnemonic entry, mnemonic display). The package contributes no UI, and it keeps the two secrets — the auth password and the at-rest password — distinct. - The key-store choice —
FileKeyStoreversusDpapiKeyStore, and the directory they live in. - The auth endpoint configuration —
AuthUrl,BriefcasesServerUrl, and the tokenScopeonBriefcasesIdentityOptions.
| Product | Versions 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. |
-
net10.0
- BouncyCastle.Cryptography (>= 2.7.0)
- Epsitec.Briefcases.Sdk (= 6.1.0.2638)
- Epsitec.Rezo.Api (>= 11.8.5.2636)
- Konscious.Security.Cryptography.Argon2 (>= 1.3.1)
- Microsoft.AspNetCore.Http.Connections.Client (>= 10.0.12)
- Microsoft.AspNetCore.Http.Connections.Common (>= 10.0.12)
- Microsoft.AspNetCore.SignalR.Client (>= 10.0.12)
- Microsoft.AspNetCore.SignalR.Protocols.Json (>= 10.0.12)
- Microsoft.Extensions.Configuration (>= 10.0.12)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.12)
- Microsoft.Extensions.DependencyInjection (>= 10.0.12)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Http (>= 10.0.12)
- Microsoft.Extensions.Http.Resilience (>= 10.10.0)
- Microsoft.Extensions.Logging (>= 10.0.12)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Logging.Configuration (>= 10.0.12)
- Microsoft.Extensions.ObjectPool (>= 10.0.12)
- Microsoft.Extensions.Options (>= 10.0.12)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.12)
- Microsoft.IdentityModel.Tokens (>= 8.22.0)
- Neo.Cryptography.BLS12_381 (>= 3.9.0)
- System.Security.Cryptography.ProtectedData (>= 10.0.12)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 6.1.0.2638 | 38 | 9/17/2026 |
| 6.0.1.2638 | 49 | 9/17/2026 |
| 6.0.0.2638 | 46 | 9/16/2026 |
| 5.3.2.2638 | 159 | 9/14/2026 |
| 5.3.1.2637 | 108 | 9/11/2026 |
| 5.3.0.2635 | 251 | 8/26/2026 |
| 5.2.0.2634 | 131 | 8/18/2026 |
| 5.1.1.2633 | 229 | 8/14/2026 |
| 5.1.0.2633 | 114 | 8/14/2026 |
| 5.0.1.2633 | 104 | 8/13/2026 |
| 5.0.0.2633 | 102 | 8/12/2026 |
| 4.0.4.2632 | 173 | 8/5/2026 |
| 4.0.3.2632 | 71 | 8/5/2026 |
| 4.0.1.2630 | 170 | 7/24/2026 |
| 4.0.0.2630 | 74 | 7/24/2026 |
| 3.3.3.2630 | 77 | 7/23/2026 |
| 3.3.1.2630 | 109 | 7/23/2026 |
| 3.3.0.2630 | 74 | 7/22/2026 |
| 3.2.0.2630 | 73 | 7/22/2026 |
| 3.1.4.2630 | 132 | 7/20/2026 |