Account join key

How a standalone device reports a per-tenant key for each account that ran an agent, so a fleet service can match device records to its directory accounts without the device sending names or SIDs.

The account join key lets your fleet service match the accounts on a device to the accounts in your directory. The device never sends a name, UPN or SID. It sends a keyed digest that only a holder of your tenant secret can compute.

It is off unless you turn it on. It is not available under Secure Client. It needs DefenseClaw 1.0.7 or later on Linux, macOS and Windows standalone devices. On macOS only Active Directory accounts get a key.

Turn it on

  1. Make a tenant secret: 32 random bytes, base64url encoded. Use it only for this.

    openssl rand 32 | basenc --base64url | tr -d '=\n'
  2. Store it on each device as the protected credential account-join-key:

    printf '%s' "$SECRET" | sudo /opt/defenseclaw/bin/defenseclaw-gateway enterprise secret set --name account-join-key --from-stdin

    A value that is not 32 bytes in base64url is refused with secret_malformed. Like the other protected credentials, it is readable by root or SYSTEM and the gateway service account. It is never printed or logged.

  3. Turn it on in config.yaml, with the SHA-256 of the stored value, and sign the bundle with --min-product-version 1.0.7 and --secret-file account-join-key=<file holding the secret>:

    enterprise:
      profile: standalone
      account_join:
        mode: on
        generation: 1
        secret_sha256: <hex digest only: printf '%s' "$SECRET" | sha256sum | cut -d' ' -f1>

    The bundle binds the secret by a keyed digest (secret_refs, an optional reference), never the value. Signing refuses a value that is not the one secret_sha256 names. A device that stores another value refuses the bundle (secret_ref_mismatch) and keeps the bundle it has, and enterprise secret set refuses a value the confirmed bundle does not sign. A device that has no account-join-key yet applies the bundle and reports secret_missing. A config installed without a bundle has no binding: a device whose stored secret has another digest then reports no key for that generation (secret_mismatch).

What the device reports

Your management agent reads the keys with enterprise account-keys, as root or from an elevated administrator prompt (on macOS the binary is /opt/cisco/defenseclaw/bin/defenseclaw-gateway). A standard account gets not_root. On a computer with only a per-user install there are no account join keys, and the command says so (not_installed):

sudo /opt/defenseclaw/bin/defenseclaw-gateway enterprise account-keys
{"schema_version":1,"account_join":{
  "state": "on",
  "generations": [{ "generation": 1, "account_keys": ["ak1:1:a2f018c3bbe491743da16cc799c63d0b"] }],
  "accounts_without_key": 1,
  "reason_codes": ["no_directory_id"]}}
  • Keys are computed when the command runs. They are not stored, logged, exported to telemetry, written to bundle receipts or shown to users. The process that computes the keys turns off its own crash dumps before it reads the secret (no core file on Linux and macOS, not dumpable on Linux, no heap in a Windows Error Reporting report). A dump an administrator configures for every process (Windows LocalDumps, Linux suid_dumpable 2) is inside the trust boundary. Send them only on your fleet service's channel; do not print them in MDM script output.
  • enterprise status --json and enterprise verify --json (as root or an administrator) carry only a summary: state, key_count per generation, accounts_without_key and reason_codes. They never carry a key, so MDM logs of status hold none.
  • The accounts are the enrolled accounts the hook guardian resolved and saw in the last 30 days, at most 64 per device (the most recently seen).
  • A standard user gets no keys and no summary.

Which identifier is used

The device uses the first one it has, and only when the directory verified it:

OSFirstFallback
Linux (SSSD)The account's domain SID from SSSDThe userPrincipalName from SSSD InfoPipe
WindowsThe account's SID, for a domain account onlyThe UPN from the directory (Entra ID accounts: from the Windows identity store)
macOSThe account's domain SID (SMBSID), read from its Active Directory node, for an Active Directory account only(none)

Local accounts, machine-domain accounts and accounts with a derived or kept (not currently verified) UPN get no key (no_directory_id). So do macOS LDAP and Platform SSO accounts, and a macOS Active Directory account whose SID no pass has read from its domain yet. The guardian reads the SID from the domain each pass. While the domain does not answer (off the network, or the domain controller unreachable), it keeps the SID it last read, so the key does not change. An account the domain reports as gone loses its SID and its key. sIDHistory is never used. On Windows a SID counts as a domain account's when the directory translated it to a UPN; an account of a trusted forest counts too.

A person's SID key and UPN key differ. Compute both from your directory. A UPN rename changes the UPN key, and a move to a new domain changes the SID key.

How the key is computed

K   = HKDF-SHA256(secret, salt = none, info = "defenseclaw.account-join.v1" || 0x00 || u32be(generation), 32 bytes)
M   = u32be(len(L)) || L || u32be(len(T)) || T || u32be(len(C)) || C
key = "ak1:" + generation + ":" + first 32 hex characters of HMAC-SHA256(K, M)
  • L is defenseclaw.account-join.v1.
  • T is the tenant ID of the signed bundle anchors, in lowercase.
  • C is sid: and the SID, or upn: and the UPN.
    • SID: S-1-5-21-a-b-c-RID in decimal with no leading zeros, RID 1000 or more.
    • UPN: Unicode NFC, then Unicode simple case folding (CaseFolding.txt status C and S, Unicode 15.0) of the whole string. İ (U+0130) has no simple folding and is kept.

Test vectors, with the secret bytes 00 01 02 … 1f and tenant contoso-prod:

GenerationCKey
1sid:S-1-5-21-1004336348-1177238915-682003330-1105ak1:1:fc6093568064ee498f85407a3a3afa63
1upn:alice@contoso.comak1:1:a2f018c3bbe491743da16cc799c63d0b
2upn:alice@contoso.comak1:2:ed70fcab4483d61004b5768ff3db16b3

Rotate the secret

  1. Store the new secret as account-join-key-next and sign a config with next_generation, its next_secret_sha256 and next_not_after (with --secret-file for both secrets), at most 30 days after you sign the bundle. Signing refuses a later one. The device measures the 30 days from the signing time of the confirmed bundle, not from its own clock, which it uses only for a config installed without a bundle:

    account_join:
      mode: on
      generation: 1
      secret_sha256: <digest of the current secret>
      next_generation: 2
      next_secret_sha256: <digest of the new secret>
      next_not_after: "2026-12-01T00:00:00Z"

    The device reports keys for both generations until next_not_after, then only the current one (next_generation_expired).

  2. When every device reports generation 2, promote it: sign generation: 2 with its secret_sha256 and --secret-file account-join-key=<new secret>, drop the next_ keys, store the new secret as account-join-key with enterprise secret set --name account-join-key --from-stdin --bundle <that bundle> (the confirmed bundle still binds the old value), apply the bundle, and remove account-join-key-next. Until the stored secret and the signed digest agree, the device reports no key for generation 2, never a wrong one.

Removing account-join-key-next without promotion changes nothing for generation 1. A device refuses a generation lower than one it already reported (generation_rollback). To allow it, remove account-join-generation.json from the lifecycle folder as root.

Rotation recovers from a leaked secret. It does not unlink history: during the overlap your service can link old keys to new ones.

Reason codes

CodeMeaning
feature_offenterprise.account_join is off or absent (account-keys output only).
secret_missingNo account-join-key is stored.
secret_malformedA stored secret is not 32 bytes in base64url. No other secret is used instead.
secret_unreadableA stored secret could not be read.
secret_mismatchA stored secret is not the value the signed config names by its digest.
generation_state_unreadableaccount-join-generation.json cannot be read. Nothing is reported until you remove it.
generation_invalidA generation number or next_not_after is not usable.
generation_rollbackThe generation is lower than one this device reported.
next_generation_expirednext_not_after has passed.
tenant_missingNo verified bundle anchors with a tenant ID.
no_directory_idAn account has no verified SID or UPN.
canonicalization_failedAn identifier is not in a form the key accepts.
account_capMore than 64 accounts; the 64 seen most recently get keys.

Errors and reason codes never contain a name, a SID, a UPN, the secret or part of a key.

Privacy

  • The key is pseudonymised personal data under the GDPR. Anyone with the tenant secret can compute the key of any identifier, so treat the secret like a directory export.
  • Purpose: matching device records to directory accounts in your fleet service. Do not use it for anything else.
  • Your organization is the controller. The secret stays with you: DefenseClaw does not hold it.
  • Retention on the device: an account not seen for 30 days drops out of the report.
  • Local administrators and root are inside the trust boundary: they can read the secret.

Before you downgrade

DefenseClaw 1.0.6 and older refuse a config with enterprise.account_join. Apply a bundle without it before you install an older build. A management agent that validates status documents must use the 1.0.7 lifecycle result schema.