HOTP authority and recovery
Status: released in CLI 0.16.0 and Python 0.13.0. Hosted staging import, ownership and retry acceptance passed; production backend acceptance remains separate. Vault #241. The commands below require the matching HOTP backend and these released clients. Released TOTP behavior stays unchanged.
HOTP advances when you explicitly request a new code. It has no time-step expiry. DreamLake becomes the generator; the external login service still validates codes and controls resynchronization. Stop generating codes from pass/mobile copies before assigning DreamLake ownership. This consent cannot disable those copies. No inventory, import preview, listing, seed retrieval or background job generates an HOTP code.
Import and choose the counter owner
Import selected records with the existing command. HOTP defaults inactive and preserves the source counter. Pass-otp generates using its stored counter plus one; activation normalizes that convention to DreamLake's encrypted next-unused counter. The source files are never modified or deleted. The first generator supports SHA1 with a seed of at least 128 bits and 6–8 digits. Other algorithms remain stored inactive; activation rejects them. Counters use exact unsigned 64-bit arithmetic, and exhaustion never wraps.
# Inspect without uploading or generating.
dreamlake vault import --pass-otp -p ge/otp --dry-run --store /canonical/pass-store
# Upload only the selected registration, initially inactive.
dreamlake vault import --pass-otp -p ge/otp --store /canonical/pass-store --select vpn
# Check the revision, then explicitly assign counter ownership; no code emitted.
dreamlake vault show -n ge/otp/vpn
dreamlake vault otp -n ge/otp/vpn --activate --counter-owner dreamlake --if-match 1
# Alternatively, explicitly assign ownership during import into a new name.
dreamlake vault import --pass-otp -p ge/otp --store /canonical/pass-store \
--select other-login --hotp-owner dreamlakepreview = client.vault.import_entries(source="pass-otp", prefix="ge/otp",
store="/canonical/pass-store", dry_run=True)
result = client.vault.import_entries(source="pass-otp", prefix="ge/otp",
store="/canonical/pass-store", select=["vpn"])
entry = client.vault.show("vpn", prefix="ge/otp")
client.vault.activate_otp("vpn", prefix="ge/otp", counter_owner="dreamlake",
if_match=entry["revision"])
# Explicit ownership during a new import:
result = client.vault.import_entries(source="pass-otp", prefix="ge/otp",
store="/canonical/pass-store", select=["other-login"], hotp_owner="dreamlake")Python never prompts. Selected imports remain create-only: existing names conflict, and uncertain imports are not blindly retried. Partial successes are retained. The explicit ownership option is rejected during preview or SSH imports.
Generate once and recover a lost response
A request file records only the account, origin, name, immutable entry ID,
original revision and random request ID. It contains no token, seed, counter or
code. It is exclusively created with mode 0600; symlinks, hard links, unsafe
permissions and malformed/mismatched files are rejected. Use a private writable
POSIX directory on a filesystem that supports durable file and directory fsync.
Both are synchronized before issuance, including when reusing an existing intent.
If either synchronization fails, no issuance is sent; keep the file and retry it.
These checks cannot guarantee durability on filesystems or hardware that do not
honor fsync, or if another process deletes the file. Unsupported platforms fail
closed. The stored revision is never refreshed during retries.
# A new file means one intentional request for a new code.
dreamlake vault otp -n ge/otp/vpn --request-file ./vpn-attempt-001.json
# If a response is lost, reuse exactly the same file.
dreamlake vault otp -n ge/otp/vpn --request-file ./vpn-attempt-001.json --to-jsoncode = client.vault.otp("vpn", prefix="ge/otp", request_file="./vpn-attempt-001.json")
# After an uncertain response, restart and repeat with the same file.
result = client.vault.otp("vpn", prefix="ge/otp",
request_file="./vpn-attempt-001.json", to_json=True)Code output is deliberately secret; keep it out of logs. JSON includes the code,
request ID, resulting revision, replay indicator and recoverUntil. That deadline
is receipt availability, not code validity. Replaying an older issuance does
not promise the login service still accepts its code. An error after dispatch is
an uncertain outcome: never create a fresh file merely to retry it.
The encrypted issuance receipt lasts 24 hours. Counter update and receipt commit in one transaction. Concurrent identical intents get the same result; different intents with the same revision have one winner. A stale original revision/entry ID prevents double advancement even after receipt deletion or name recreation. Missing receipts remain inconclusive. Check the entry before intentionally requesting another code after recovery becomes unavailable.
HTTP and lifecycle boundaries
POST /v1/vault/otp/activate accepts name, expectedRevision and counterOwner
(dreamlake). It returns entry metadata and generates no code.
POST /v1/vault/otp keeps the existing TOTP name-only contract. HOTP additionally
requires entryId, expectedRevision and requestId; these are the low-level
immutable intent tuple. Authentication is owner-only. A scoped seed grant does
not authorize generation, and rejected calls do not consume one-time keys.
Seed, algorithm, label, issuer and counter stay encrypted. Receipts encrypt the code separately under tenant/entry/request context. List/show and diagnostics never include these secrets. Generic replacement of a HOTP entry is rejected to prevent counter rollback and type-change bypasses. Rotation/resynchronization are explicit future work; no silent counter reset is provided. Retirement and expiry deny generation/replay; restore does not reset the counter. Purge and receipt TTL remain separate lifecycles.
Evidence and remaining gates
The local suite checks RFC4226 vectors, exact counters/exhaustion, activation,
concurrent intents, receipt expiry/deletion, recreated identity, tenant/scoped
isolation, transaction rollback and retirement during KMS. The reusable
dreamlake-server/scripts/test-vault-hotp-clients.py drives real GPG, isolated
Mongo and HTTP using both client subprocesses, drops two committed responses,
restarts clients and verifies one counter advancement. Source hashes stay intact.
These checks use synthetic registrations; hosted acceptance and package releases
remain required before presenting this as shipped behavior.