Composing an echo secret
Audience: an app developer writing the enrollment screen for a vault in echo mode — and, through that screen, the person choosing the secret. Bundle: core only —
@noy-db/hubwithsecretMode: 'echo'. No satellite package, nowith*()service.
An echo secret is one sentence, enrolled as three parts. The user types the first part (the prompt), the vault answers with the second (the echo), the user recognizes it and types the third (the key). That middle step is the whole point: a phishing page can copy your unlock screen pixel for pixel, but it cannot produce the middle of a sentence it has never seen.
Which makes the choice of sentence the load-bearing decision. The mechanism is only as good as the echo the user picks, and the failure mode is specific and common: people reach for autobiography, and autobiography is searchable. This page is the guidance to put in front of them.
Prerequisites
- A vault in echo mode —
secretMode: 'echo'atcreateNoydb. The mode is chosen at enrollment and recorded in the keyring; see session-tiers for the full API, the reveal policies, and how to migrate an existing vault withrotateSecret. - An enrollment screen with three fields. Not one field with separators — the three parts are three inputs. A single-field entry for an echo vault cannot work and must not be offered.
- Nothing else. No package to install, no strategy to wire in.
What this gets you
| What the user gets | Which part provides it |
|---|---|
| A phishing page cannot complete the unlock | the echo, revealed by the vault before the user finishes |
| No single-string form to be tricked into typing | the three-part key derivation — no concatenation is equivalent to it |
| A retrieval cue for a secret typed twice a year | the echo, which is the middle of the user's own sentence |
| Resistance to someone who guesses the first part | the key, which the echo does not disclose |
Rule 1 — one sentence, split in three
Do not ask for three unrelated secrets. Ask for one sentence the user will still recognize in three years, then split it at two natural pauses:
prompt "the part you type to start"
echo "the part we will show you"
key "the part you type to finish"
A sentence survives in memory as a unit; three unrelated fragments do not. The split also has to fall where the sentence bends, so that seeing the echo makes the key feel inevitable rather than arbitrary.
Shape to aim for:
const parts = {
prompt: 'mi chiamo vicio',
echo: 'ma da piccolo al santanna mi chiamavano',
key: 'ciccio',
}
That example is the right shape — a short opener, a longer middle that carries the recognition, a short ending that completes it — and deliberately the wrong content. Every clause in it is a true biographical fact: a real name, a real school, a real childhood nickname. Which brings us to the rule that matters most.
Rule 2 — the echo must be unguessable by an acquaintance
The echo is what an attacker most wants, because a guessed prompt yields a revealed echo, and an echo in hand makes a perfect imitation of your unlock screen possible. So the question to put to the user is not "is this memorable?" but:
Could someone who knows me — or someone who reads my public profiles for ten minutes — write this sentence?
If the answer is anything but a firm no, the sentence fails.
Never use (all of it is public, inferable, or asked over dinner): your name, a parent's or child's name, a school or university, a home town or street, a nickname anyone has used out loud, an employer, a wedding date, a pet's name, a sports team, a birthday.
Use instead, either of:
- A private memory — something true that was never told to anyone. The thought you had in a specific waiting room; what you were reading the night before a move; the thing you almost said and did not. True memories are durable, and there is nobody to have overheard them.
- A deliberately false fact — a real-sounding sentence that is not true of you. Invented, memorized once, and therefore unavailable to anyone researching your actual life. A false fact is often easier to keep private than a true one, because it never appears anywhere.
The hub cannot check any of this. No validator can distinguish a private memory from a published one, so the guidance lives here and on your enrollment screen, and nowhere in the code.
Rule 3 — longer echo, better echo
The echo carries the recognition, so length costs the user nothing at unlock — they read it, they do not type it. Length buys two things:
- Recognition quality. A seven-word middle is unmistakably yours; a two-word one might be anyone's.
- Resistance to offline guessing. Anyone holding a copy of the keyring or pod file can test candidate echoes offline against the stored verifier, without limit. Each guess costs 600,000 PBKDF2 iterations, and there is no minimum-entropy floor on an echo — so the only thing standing between a short, ordinary echo and a determined file-holder is how ordinary it is. See the threat model for the full accounting.
Aim for a middle part of five words or more. Length in the key matters for the same reason and is worth encouraging, though the key is typed, so there is a real trade against usability.
Rule 4 — write it as a natural sentence
The validator was tuned for this: echo-mode parts allow words of one character or more, relaxed from the standard phrase's three-character floor, precisely so that Romance-language function words — mi, da, al, è — do not get a valid sentence rejected. Word-count floors are unchanged: at least three words in the prompt, at least six across all three parts combined.
What has not been relaxed is the character pattern: lowercase letters and single spaces only. No capitals, no digits, no punctuation. A sentence with a period or an apostrophe in it must lose them at enrollment:
import { validateEchoSecret } from '@noy-db/hub'
validateEchoSecret({
prompt: 'mi chiamo vicio',
echo: 'ma da piccolo al santanna mi chiamavano',
key: 'ciccio',
})
// → { ok: true, words: 11 }
validateEchoSecret({ prompt: 'ciao', echo: 'come stai oggi amico', key: 'bene grazie' })
// → { ok: false, reason: 'too-few-words', minimum: 3, got: 1 } — the prompt is one word
Show the user which part failed. assertStrongEchoSecret throws a WeakSecretError carrying a reason and a suggestion, and the suggestion is prompt-specific when the prompt is what failed.
Rule 5 — set a mask hint, and keep it a hint
The revealed echo appears on a screen, so it is shoulder-surfable. The keyring carries an optional echoMaskHint, stored verbatim and handed back to the player as ceremony.maskHint:
const db = await createNoydb({
store, user: 'vicio', secretMode: 'echo', secret: parts,
echoMaskHint: 'first-letters',
})
The hub attaches no meaning to the string — it neither validates nor interprets it. That makes the vocabulary yours, so pick a small one and use it consistently across every surface that opens your vaults. A workable set:
| Hint | What a player renders |
|---|---|
first-letters | the first letter of each word, the rest as dots — enough to recognize, little to steal |
full | the echo in full; for a private device, or when the user asked for it |
tap-to-reveal | masked until the user deliberately reveals it |
Two constraints on whatever vocabulary you choose. The hint is plaintext keyring metadata, like the salt — so it must never contain any part of the echo. And it is a hint: a player that does not recognize your vocabulary still has to mask by its own judgment rather than fall back to showing everything.
Rule 6 — teach the warning, and mean it
Enrollment is where the user learns what the ceremony is for. One sentence on the screen, and it has to be unambiguous:
If the app ever shows you the wrong middle part — or shows you none at all — stop. Do not type the last part. Close it, open the official app yourself, and change your secret.
Then make your own application obey it. When a user does not recognize the echo, the ceremony is over: show the warning, do not call complete(), and do not offer a "skip this step" or "verification unavailable" affordance. An escape hatch is the only thing that can turn a defeated phishing attempt back into a successful one — and once one application ships one, every phishing page will imitate it.
Rate limiting is on you too: the hub keeps no attempt counter, so repeated WrongPromptErrors should trigger your own backoff or lockout (on-threat covers the ready-made version).
Setup
The complete enrollment call, with everything above applied:
import { createNoydb, assertStrongEchoSecret } from '@noy-db/hub'
import { toBrowserIdb } from '@noy-db/to-browser-idb'
const parts = {
prompt: collected.prompt, // three separate inputs, never one field
echo: collected.echo,
key: collected.key,
}
assertStrongEchoSecret(parts) // surface reason + suggestion in the form, before creating anything
const db = await createNoydb({
store: toBrowserIdb(),
user: 'vicio',
secretMode: 'echo',
secret: parts,
echoMaskHint: 'first-letters',
validateSecret: true,
})
const vault = await db.openVault('acme') // the keyring and its echo block are written here
Verify
Two checks, both worth running once against your own enrollment screen.
The vault reveals exactly what you enrolled:
import { beginEchoUnlock } from '@noy-db/hub'
const ceremony = await beginEchoUnlock(store, 'acme', { userId: 'vicio', prompt: parts.prompt })
console.log(ceremony.reveal === parts.echo) // → true
console.log(ceremony.maskHint) // → 'first-letters'
const unlocked = await ceremony.complete({ key: parts.key })
No joined string opens it — the property the whole design rests on:
import { EchoCeremonyRequiredError } from '@noy-db/hub'
const joined = `${parts.prompt}#${parts.echo}#${parts.key}`
const wrong = await createNoydb({ store, user: 'vicio', secret: joined })
try {
await wrong.openVault('acme')
} catch (err) {
console.log(err instanceof EchoCeremonyRequiredError) // → true
}
Any separator behaves the same way, because no single string is equivalent to the three-part derivation. If your unlock screen has a field that would accept that string, it is a phishing flow you shipped yourself — remove it.
See also
- session-tiers — the full echo API: reveal policies, the ceremony, rotation and migration, pod recipients, errors.
- Threat model — attacker grading, and what echo mode does not hide.
- Unlock ceremonies — how tier 1 composes with passkeys, session policy, and recovery codes.
on-threat— lockout and duress handling around the unlock ceremony.