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
                    
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="Epsitec.Briefcases.Identity" Version="6.1.0.2638" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Epsitec.Briefcases.Identity" Version="6.1.0.2638" />
                    
Directory.Packages.props
<PackageReference Include="Epsitec.Briefcases.Identity" />
                    
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 Epsitec.Briefcases.Identity --version 6.1.0.2638
                    
#r "nuget: Epsitec.Briefcases.Identity, 6.1.0.2638"
                    
#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 Epsitec.Briefcases.Identity@6.1.0.2638
                    
#: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=Epsitec.Briefcases.Identity&version=6.1.0.2638
                    
Install as a Cake Addin
#tool nuget:?package=Epsitec.Briefcases.Identity&version=6.1.0.2638
                    
Install as a Cake Tool

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 auth with 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 — IdentityPrompts collects them separately, and the unlock / recover / provision / publish operations only ever receive atRestPassword.

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 the auth hkid from the two signature keys in UserPublicKeys.
  • BriefcasesKeysetBridge.ToUserPublicKeys(normalizedKeyset) maps a normalized auth keyset back to the four briefcases public-key slots.
  • BriefcasesKeysetBridge.MatchesPublicKeys(normalizedKeyset, publicKeys) verifies both the hkid and the four SPKI DER public-key slots.
  • BriefcasesKeysetProfiles.Policy is the profile policy for keysets briefcases can consume: ecdsa-p256-sha384, ml-dsa-65, x25519, and ml-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-cli companion 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, or briefcases 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 KeyStoreBase gain the envelope and the refusal rules through the base class; overriding LegacyFileSuffix is 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

  1. UI promptsIdentityPrompts (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.
  2. The key-store choiceFileKeyStore versus DpapiKeyStore, and the directory they live in.
  3. The auth endpoint configurationAuthUrl, BriefcasesServerUrl, and the token Scope on BriefcasesIdentityOptions.
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

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
Loading failed