web: a switchable database file, and a passkey that says whose it is
Two things Paul asked for after using it. **Starting fresh.** The platform parks an instance's state when the instance is deleted — "parked data stays" — so deleting and recreating one does not reset anything, and there is no purge verb. That leaves two options: an endpoint that wipes the database, or pointing at a different file. An endpoint that destroys every account is not a thing to put in a password manager however well it is guarded, so CONTRA_DB_NAME selects which file inside the platform's state directory to open. Setting it starts clean and leaves the old data exactly where it was — reversible, and it destroys nothing. Only a bare file name is accepted, never a path, so it cannot reach outside the state directory. **The passkey label.** It showed as "oncarlos", which is the registrable domain and not ours to choose from inside the page — only a custom domain changes the site line. What IS ours is the account line, and it was reading "CONTRA vault" for everybody. It now carries the address: user.name is the email and displayName is "<email> CONTRA". The server cannot supply that — it holds a blind index, and the signup link carries only a token — so step one remembers what was typed, in sessionStorage, for this tab. It dies with the tab, never leaves the browser, and is read once and deleted at the moment it labels the credential. Finishing signup in a different browser than it started in is a real case, so the fallback is the account handle in the same short form the Emergency Kit prints — a passkey and a printed card then name the same account the same way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 files changed,
+70
−5
cmd/contra/main.go+26 −1internal/contra/static/pages/passkey.js+29 −2internal/contra/static/pages/signup.js+13 −0internal/contratest/jsmodules_test.go+2 −2
Intent
Attributed to this commit: 3380 g CO₂e (369598700 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on web/signup-steps, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.
diff --git a/cmd/contra/main.go b/cmd/contra/main.go| index ddce9c8..bd19fdd 100644 |
| --- a/cmd/contra/main.go |
| +++ b/cmd/contra/main.go |
| @@ -6,6 +6,7 @@ import ( |
| "encoding/hex" |
| "log/slog" |
| "os" |
| + "path/filepath" |
| "github.com/carlosframework/rastrillo" |
| "github.com/carlosframework/rastrillo/db" |
| @@ -28,7 +29,31 @@ func main() { |
| os.Exit(1) |
| } |
| - d, err := db.Open(opts.DBPath, logger) |
| + // CONTRA_DB_NAME selects which database file inside the platform's state |
| + // directory to open. It exists so a preview instance can be started clean |
| + // without anything in this binary being able to destroy data. |
| + // |
| + // The platform parks an instance's state when the instance is deleted — |
| + // "parked data stays" — so recreating one does NOT reset it, and there is |
| + // no purge verb. The alternatives were an endpoint that wipes the |
| + // database, which is not a thing to put in a password manager however |
| + // well guarded, or this: point at a different file and leave the old one |
| + // where it is. Reversible, and destroys nothing. |
| + // |
| + // Only the base name is taken, never a path: the directory stays the one |
| + // the platform resolved, so this cannot be used to read or write outside |
| + // the state directory. |
| + dbPath := opts.DBPath |
| + if name := os.Getenv("CONTRA_DB_NAME"); name != "" { |
| + if name != filepath.Base(name) || name == "." || name == ".." { |
| + logger.Error("CONTRA_DB_NAME must be a bare file name", "got", name) |
| + os.Exit(1) |
| + } |
| + dbPath = filepath.Join(filepath.Dir(opts.DBPath), name) |
| + logger.Warn("opening a non-default database", "file", name) |
| + } |
| + |
| + d, err := db.Open(dbPath, logger) |
| if err != nil { |
| logger.Error("open database", "err", err) |
| os.Exit(1) |
diff --git a/internal/contra/static/pages/passkey.js b/internal/contra/static/pages/passkey.js| index 975c753..de67f11 100644 |
| --- a/internal/contra/static/pages/passkey.js |
| +++ b/internal/contra/static/pages/passkey.js |
| @@ -17,6 +17,26 @@ import { hkdf, kcv, LABEL_PRF_KEK } from "../crypto/kdf.js"; |
| import { aad, seal, OBJECT_WRAP_PRF } from "../crypto/sealed.js"; |
| import { accountKeys, unlock } from "../crypto/vaultkeys.js"; |
| +// signupEmail is the address typed at step one, in this tab, if the ceremony |
| +// is finishing in the browser it started in. Read once and then forgotten: |
| +// there is no reason for it to outlive the credential it labels. |
| +function signupEmail() { |
| + try { |
| + const key = "contra/v1/signup-email"; |
| + const value = window.sessionStorage.getItem(key); |
| + if (value) window.sessionStorage.removeItem(key); |
| + return value; |
| + } catch { |
| + return null; |
| + } |
| +} |
| + |
| +// accountHandle is the fallback label: the same short form the Emergency Kit |
| +// prints, so a passkey and a printed card name the same account the same way. |
| +function accountHandle(accountIdHex) { |
| + return accountIdHex.slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, "$1-$2"); |
| +} |
| + |
| const box = document.querySelector("[data-status]"); |
| const button = document.querySelector("[data-add-passkey]"); |
| @@ -79,6 +99,13 @@ async function enrol() { |
| // the PRF's input. It is not a secret and does not need to be one. |
| const credSalt = randomBytes(32); |
| + // What a password manager shows. The site line comes from the domain and |
| + // is not ours to choose — on contra.bdj.oncarlos.com it reads "oncarlos", |
| + // and only a custom domain changes that. The account line is user.name and |
| + // user.displayName, which are ours, so they carry the address and the |
| + // product name. |
| + const label = signupEmail() || `account ${accountHandle(bootstrap.account_id_hex)}`; |
| + |
| say("Waiting for your passkey…"); |
| // userVerification is required in every ceremony. CTAP2 keeps distinct |
| // with-UV and without-UV secrets, and mixed UV policy is a known cause of a |
| @@ -89,8 +116,8 @@ async function enrol() { |
| rp: { name: "CONTRA" }, |
| user: { |
| id: opened.accountId, |
| - name: "CONTRA vault", |
| - displayName: "CONTRA vault", |
| + name: label, |
| + displayName: `${label} CONTRA`, |
| }, |
| pubKeyCredParams: [ |
| { type: "public-key", alg: -7 }, |
diff --git a/internal/contra/static/pages/signup.js b/internal/contra/static/pages/signup.js| index 93bd056..8d3d1fe 100644 |
| --- a/internal/contra/static/pages/signup.js |
| +++ b/internal/contra/static/pages/signup.js |
| @@ -89,6 +89,19 @@ if (beginForm) { |
| say(beginForm, "Sending your link…"); |
| try { |
| await net.post("/auth/begin", { email }); |
| + // Remembered for this tab so the passkey step can label the credential |
| + // with the address rather than with nothing. sessionStorage, not local: |
| + // it dies with the tab, it never leaves the browser, and it is the |
| + // address the person just typed into this same window. |
| + // |
| + // The server cannot supply this — it holds a blind index and the link |
| + // carries only a token — so if signup finishes in a different browser |
| + // the passkey falls back to the account handle. |
| + try { |
| + window.sessionStorage.setItem("contra/v1/signup-email", email); |
| + } catch { |
| + // A browser that refuses session storage just gets the fallback. |
| + } |
| window.location.assign("/signup/sent"); |
| } catch (err) { |
| if (err instanceof RequestError && err.status === 400) { |
diff --git a/internal/contratest/jsmodules_test.go b/internal/contratest/jsmodules_test.go| index 99594e2..711fc6c 100644 |
| --- a/internal/contratest/jsmodules_test.go |
| +++ b/internal/contratest/jsmodules_test.go |
| @@ -46,9 +46,9 @@ var jsHighWater = map[string]int{ |
| // Page controllers. Thin on purpose: every decision that matters lives in |
| // a crypto module, and these only move text between a form and one. |
| - "pages/passkey.js": 170, |
| + "pages/passkey.js": 197, |
| "pages/signin.js": 49, |
| - "pages/signup.js": 245, |
| + "pages/signup.js": 258, |
| "pages/vault.js": 170, |
| } |