diff --git a/packages/access-api/postmark/welcome.html b/packages/access-api/postmark/welcome.html index 0c41fe1ef..fd8bfd155 100644 --- a/packages/access-api/postmark/welcome.html +++ b/packages/access-api/postmark/welcome.html @@ -1,5 +1,6 @@

Hi {{email}}! To complete your {{product_name}} registration, we just need to verify your email address.

+

Before clicking the button below, please confirm the app you are trying to register is showing this phrase: {{match_phrase}}

diff --git a/packages/access-api/postmark/welcome.txt b/packages/access-api/postmark/welcome.txt index 405fa4050..de519a5f5 100644 --- a/packages/access-api/postmark/welcome.txt +++ b/packages/access-api/postmark/welcome.txt @@ -1,7 +1,11 @@ Hi {{email}}! To complete your {{product_name}} registration, we just need to verify your email address. -Please visit the following link in your web browser: +Please confirm the app you are trying to register is showing this phrase: + +{{match_phrase}} + +If it is, please visit the following link in your web browser: {{action_url}} diff --git a/packages/access-api/src/service/index.js b/packages/access-api/src/service/index.js index 74fbd46f0..84e90148d 100644 --- a/packages/access-api/src/service/index.js +++ b/packages/access-api/src/service/index.js @@ -14,6 +14,7 @@ import { voucherClaimProvider } from './voucher-claim.js' import { voucherRedeemProvider } from './voucher-redeem.js' import * as uploadApi from './upload-api-proxy.js' import { accessAuthorizeProvider } from './access-authorize.js' +import { generateNoncePhrase } from '../utils/phrase.js' import { accessDelegateProvider } from './access-delegate.js' import { accessClaimProvider } from './access-claim.js' import { providerAddProvider } from './provider-add.js' @@ -195,6 +196,7 @@ export function service(ctx) { const encoded = delegationToString(inv) const url = `${ctx.url.protocol}//${ctx.url.host}/validate-email?ucan=${encoded}&mode=recover` + const nonce = generateNoncePhrase() // For testing if (ctx.config.ENV === 'test') { @@ -204,7 +206,9 @@ export function service(ctx) { await ctx.email.sendValidation({ to: capability.nb.identity.replace('mailto:', ''), url, + nonce, }) + return { matchPhrase: nonce } } ), }, diff --git a/packages/access-api/src/service/voucher-claim.js b/packages/access-api/src/service/voucher-claim.js index 28e499aef..9d6caa744 100644 --- a/packages/access-api/src/service/voucher-claim.js +++ b/packages/access-api/src/service/voucher-claim.js @@ -1,6 +1,7 @@ import * as Server from '@ucanto/server' import * as Voucher from '@web3-storage/capabilities/voucher' import { delegationToString } from '@web3-storage/access/encoding' +import { generateNoncePhrase } from '../utils/phrase.js' /** * @param {import('../bindings').RouteContext} ctx @@ -41,10 +42,13 @@ export function voucherClaimProvider(ctx) { } const url = `${ctx.url.protocol}//${ctx.url.host}/validate-email?ucan=${encoded}` + const nonce = generateNoncePhrase() await ctx.email.sendValidation({ to: capability.nb.identity.replace('mailto:', ''), url, + nonce, }) + return { matchPhrase: nonce } }) } diff --git a/packages/access-api/src/utils/email.js b/packages/access-api/src/utils/email.js index cc2b6362c..313b49ed1 100644 --- a/packages/access-api/src/utils/email.js +++ b/packages/access-api/src/utils/email.js @@ -4,6 +4,7 @@ export const debug = () => new DebugEmail() * @typedef ValidationEmailSend * @property {string} to * @property {string} url + * @property {string} nonce */ /** @@ -44,6 +45,7 @@ export class Email { product_name: 'Web3 Storage', email: opts.to, action_url: opts.url, + match_phrase: opts.nonce, }, }), }) diff --git a/packages/access-api/src/utils/phrase-words.json b/packages/access-api/src/utils/phrase-words.json new file mode 100644 index 000000000..fd8fd08c6 --- /dev/null +++ b/packages/access-api/src/utils/phrase-words.json @@ -0,0 +1,988 @@ +[ + "reflected", + "knowings", + "inkiest", + "gated", + "facilitates", + "firmly", + "priority", + "vicar", + "reconciliations", + "facades", + "earns", + "shy", + "glows", + "symptomatic", + "rumbles", + "tough", + "object", + "chip", + "privates", + "adjoin", + "punned", + "sizing", + "accidents", + "sculpture", + "commuted", + "proposals", + "pail", + "feeler", + "performed", + "footballs", + "sorority", + "revving", + "larked", + "tickling", + "continuations", + "repress", + "surlier", + "wildernesses", + "darting", + "overlaying", + "complexions", + "invoking", + "sinew", + "ampler", + "buys", + "survival", + "decoration", + "halos", + "inducement", + "snippets", + "walling", + "programmes", + "incoherence", + "incumbent", + "aquatics", + "embarrassments", + "displacement", + "bucketed", + "floor", + "rifled", + "handy", + "multiplicity", + "pants", + "bicycled", + "motorway", + "evacuates", + "minus", + "insect", + "backlogs", + "penknives", + "originator", + "photosynthesis", + "albeit", + "skewered", + "enthralls", + "departmental", + "subsidy", + "pinnacle", + "raves", + "down", + "culminating", + "overborne", + "authenticated", + "fluently", + "distend", + "horrifies", + "entitling", + "recursive", + "flutist", + "counteracting", + "licking", + "misdemeanors", + "allegiance", + "foretelling", + "burdening", + "maneuvering", + "amassed", + "moussed", + "felt", + "waist", + "implement", + "lab", + "palates", + "ambiance", + "always", + "registrations", + "endearing", + "siphon", + "viler", + "proclaiming", + "waterworks", + "gingham", + "guessed", + "confirmation", + "accord", + "humanities", + "migraines", + "inflammable", + "corroboration", + "applicant", + "revealing", + "bard", + "posted", + "omen", + "quota", + "decanter", + "plankton", + "gooier", + "executes", + "wagging", + "forest", + "passionate", + "charcoal", + "pile", + "aired", + "circa", + "tempts", + "fashion", + "devilled", + "deficiencies", + "cropping", + "fig", + "attorneys", + "lack", + "pretenses", + "chopped", + "promise", + "recounting", + "terminates", + "fabulous", + "interrupting", + "visions", + "memoirs", + "trebles", + "noblewoman", + "professor", + "journalism", + "summoned", + "enrolls", + "geographic", + "paused", + "epilogues", + "disks", + "rasping", + "penetration", + "transgresses", + "aphorisms", + "piranha", + "pigment", + "unconditionally", + "microfilmed", + "turntables", + "impish", + "vegetarianism", + "blanked", + "strata", + "exposed", + "traditional", + "phosphorus", + "menthol", + "forward", + "tethered", + "ensembles", + "sufficed", + "compacted", + "plush", + "infernos", + "solution", + "porting", + "inky", + "executors", + "jigsawed", + "steps", + "trawls", + "wilting", + "oddest", + "amazement", + "illegibly", + "jaywalked", + "mediums", + "tasks", + "pretensions", + "confiscates", + "predominance", + "watching", + "carving", + "immobilizes", + "fulfills", + "cutlets", + "eligible", + "funnel", + "seduce", + "crow", + "refreshment", + "circles", + "emirs", + "concluding", + "clink", + "denims", + "recoils", + "stall", + "hibernate", + "ponderous", + "goading", + "outcry", + "pursue", + "hesitating", + "matures", + "shoplifter", + "glamourous", + "commons", + "notched", + "debut", + "plagued", + "fiddling", + "assimilated", + "crown", + "parkway", + "staunchly", + "pilgrim", + "razes", + "garment", + "sobers", + "weeklies", + "caverns", + "spaded", + "consuming", + "variance", + "nicety", + "distract", + "assaults", + "enjoyment", + "engraves", + "endorse", + "embalms", + "seared", + "loudest", + "drys", + "tailspins", + "mushrooms", + "imagines", + "bolting", + "unwieldiest", + "dearer", + "sailboat", + "fruitless", + "toucan", + "welcomed", + "preface", + "redeems", + "participles", + "basil", + "postscripts", + "elastics", + "regulations", + "separates", + "calves", + "advertiser", + "overrides", + "taxpayer", + "shin", + "strive", + "resumption", + "volumes", + "reposing", + "coarsely", + "leapfrogging", + "masterminding", + "rereading", + "warpaths", + "unravels", + "chisels", + "carat", + "doles", + "imitate", + "scars", + "retail", + "relapses", + "flashback", + "adapts", + "monstrosities", + "clipboard", + "screech", + "reconstructs", + "milled", + "taker", + "entitled", + "kiln", + "overgrowing", + "mauled", + "mahoganies", + "baboons", + "bossed", + "shoaling", + "malaria", + "pelican", + "brow", + "homestead", + "wits", + "bookmark", + "varnished", + "issue", + "haze", + "translated", + "juggler", + "suffocate", + "thriftiest", + "buckled", + "secured", + "mystery", + "bums", + "brainiest", + "unsolved", + "overhaul", + "headquarters", + "distorter", + "airmailed", + "alibied", + "cataract", + "hoist", + "motherhood", + "goats", + "abbreviation", + "astonishing", + "retainer", + "underneaths", + "fortifies", + "seasonable", + "likeliest", + "thatcher", + "national", + "glue", + "prays", + "whiff", + "quailing", + "fragmentary", + "destroyed", + "centrally", + "stone", + "thrilled", + "defamed", + "fraternizing", + "aliasing", + "adulterates", + "slashing", + "inhabiting", + "strutted", + "interestingly", + "localized", + "sketching", + "backbones", + "differentiated", + "resignation", + "chid", + "industry", + "superbest", + "moves", + "weekended", + "festivity", + "archbishops", + "vigilance", + "excitement", + "tinning", + "inaction", + "masculine", + "lilted", + "blithe", + "miraculously", + "abetting", + "affirms", + "respecting", + "connoisseurs", + "holidays", + "bemuses", + "tricycles", + "battleships", + "tenser", + "temporal", + "another", + "established", + "intersection", + "mariner", + "dogged", + "fascinates", + "scheduled", + "strides", + "studied", + "redundant", + "parlor", + "oversee", + "overburdens", + "teeter", + "synthetics", + "comprehension", + "stockier", + "berry", + "stoves", + "covenant", + "fronts", + "bashes", + "syringe", + "poster", + "clips", + "how", + "exulting", + "microwave", + "honeymoons", + "quarterbacking", + "today", + "terrain", + "engrave", + "constitutional", + "discuss", + "uneasier", + "disruptive", + "photographed", + "hoofing", + "breadths", + "taped", + "install", + "benefits", + "altars", + "prolonged", + "bottoming", + "nationalize", + "filter", + "bleed", + "musically", + "captain", + "roles", + "sister", + "neurology", + "ethos", + "stability", + "speeds", + "distilled", + "bedlams", + "impatiences", + "bother", + "cornflakes", + "sirups", + "hypothesize", + "professions", + "underground", + "forgot", + "titled", + "researched", + "cartoonist", + "shirks", + "naughtiest", + "chidden", + "scary", + "musts", + "unblocking", + "disposition", + "stowaways", + "blemish", + "ripples", + "crackled", + "proponents", + "disqualify", + "fretful", + "unicorns", + "slewed", + "genuses", + "unskilled", + "eclipsed", + "sprinters", + "revengeful", + "provably", + "quailed", + "bulled", + "prophetic", + "timer", + "yanking", + "epic", + "inflammations", + "decomposes", + "renewal", + "unanimously", + "autumns", + "amended", + "guests", + "hared", + "firmware", + "itchiest", + "comedians", + "huskily", + "integrals", + "builder", + "golfed", + "craziness", + "aphorism", + "topical", + "bestows", + "transparency", + "whisks", + "combated", + "maltreats", + "behead", + "reforms", + "leftmost", + "deepened", + "admires", + "attachés", + "clowning", + "fair", + "embargoed", + "tenures", + "ventilate", + "broods", + "mortgaging", + "hurrayed", + "indorsements", + "retired", + "protracted", + "welder", + "lingering", + "stepping", + "sapped", + "chairmen", + "rhinoceroses", + "trapezoids", + "candled", + "brinier", + "swarming", + "pawnbroker", + "colanders", + "electronic", + "oscillated", + "lazier", + "biting", + "runny", + "scanties", + "vacationed", + "upside", + "italicizing", + "impunity", + "airstrips", + "redundancy", + "rectified", + "violets", + "kiting", + "condemnation", + "bayoneted", + "revolve", + "condenses", + "pledged", + "transitive", + "collating", + "bargaining", + "comprehensible", + "steaks", + "immoral", + "yawning", + "deer", + "result", + "female", + "cartons", + "tugging", + "memorably", + "slotted", + "overlie", + "subset", + "righting", + "sundaes", + "infant", + "manor", + "Tuesday", + "balance", + "massacring", + "oftener", + "exists", + "pallid", + "buns", + "drafted", + "pension", + "absconded", + "torpedoes", + "shampoos", + "dire", + "reinforced", + "tablespoonsful", + "triumphing", + "menus", + "sillier", + "blessing", + "dormant", + "shrapnel", + "rodent", + "trillion", + "shrimp", + "sixtieths", + "notions", + "vended", + "representative", + "prevented", + "accede", + "resign", + "cutback", + "hugely", + "chalice", + "socialized", + "tanning", + "tee", + "tablespoonful", + "heavier", + "gazetted", + "description", + "reals", + "fluffs", + "kisses", + "daubs", + "diapers", + "kayaking", + "consisting", + "encore", + "indefensible", + "touches", + "exhilarated", + "bloodshed", + "devotees", + "displease", + "stinging", + "expanded", + "trailed", + "satisfaction", + "eked", + "imprinting", + "mystified", + "mountaineering", + "blindingly", + "artists", + "swoon", + "totalled", + "accent", + "dogging", + "duration", + "cleavers", + "lusted", + "digress", + "educating", + "walled", + "hanging", + "gnawn", + "ordination", + "flicks", + "stylistic", + "ditties", + "financial", + "infertile", + "pavilions", + "millionth", + "doubting", + "heirlooms", + "vision", + "cobs", + "patches", + "proletariat", + "potency", + "majesty", + "evoked", + "limitless", + "breakthrough", + "abruptly", + "homeland", + "recovers", + "foolishly", + "ascends", + "rainier", + "poems", + "possesses", + "recycled", + "foresting", + "cuticle", + "repeats", + "choppered", + "holiness", + "remnants", + "humiliations", + "division", + "assort", + "ceremonies", + "browned", + "debilitates", + "abstraction", + "felted", + "jackknifes", + "kickoffs", + "examines", + "squids", + "beloveds", + "military", + "uniquer", + "withdrawal", + "lately", + "recovery", + "muggy", + "paragraphs", + "tiptoed", + "encounter", + "effects", + "southerly", + "acceptably", + "ploughed", + "brim", + "caricatured", + "grub", + "yanked", + "discern", + "algorithm", + "tarried", + "connecter", + "facilitated", + "history", + "fractures", + "decreeing", + "informally", + "terrors", + "fillet", + "wisp", + "hat", + "layout", + "jingle", + "congealed", + "relativity", + "fossilize", + "swift", + "serums", + "lineage", + "shamming", + "engrossing", + "recite", + "authorship", + "miserable", + "tender", + "ancientest", + "wean", + "ravine", + "matriculation", + "reticent", + "border", + "highly", + "unfriendliest", + "luxurious", + "are", + "gashed", + "trouser", + "retreating", + "deadening", + "overstepped", + "thuds", + "interrupted", + "corporals", + "oscillates", + "aggregating", + "oversees", + "mobilizes", + "shoos", + "enthusiasms", + "vertebrate", + "grill", + "dullest", + "conspicuous", + "quilt", + "stenographers", + "clarify", + "worker", + "tenanted", + "eluded", + "misprinting", + "bibs", + "protests", + "depicting", + "muting", + "raisins", + "anchovies", + "residence", + "shoestrings", + "artificially", + "skinning", + "mysteriously", + "gullets", + "advisory", + "ellipsis", + "directed", + "skied", + "chum", + "shirted", + "compatibility", + "alternating", + "cricket", + "guises", + "authorize", + "sheathed", + "chapel", + "motivating", + "flouncing", + "runniest", + "polkas", + "lament", + "starting", + "sizeable", + "shoeing", + "recuperates", + "dished", + "utensils", + "perplexities", + "slithering", + "grimes", + "leaps", + "leans", + "mistook", + "lineages", + "unheard", + "chemicals", + "udder", + "payroll", + "attesting", + "rosaries", + "wiggle", + "resembled", + "rebuffing", + "signature", + "predicated", + "reversals", + "hamsters", + "semblance", + "inferences", + "disagreed", + "sloshed", + "dullness", + "contested", + "shovelled", + "dedicating", + "extolls", + "liturgy", + "superstructure", + "entirely", + "spiked", + "befriended", + "embark", + "hankers", + "eczema", + "festoons", + "optimizes", + "racketed", + "conventionally", + "subsides", + "strictly", + "blaspheme", + "unmarked", + "lugging", + "integrated", + "textured", + "succored", + "warns", + "convoys", + "ousters", + "technological", + "equalize", + "jelled", + "lieu", + "ingests", + "synchronizing", + "intelligence", + "digitize", + "meddlers", + "bicycling", + "verier", + "quilting", + "stormed", + "nettle", + "weest", + "sarcasms", + "fiancé", + "clumsier", + "wrest", + "taxied", + "greenbacks", + "traders", + "brawniest", + "tenderized", + "travesties", + "postulated", + "reunions", + "cached", + "raspberry", + "commended", + "rhythmic", + "meteoric", + "commence", + "withdrawn", + "acclimatized", + "nickname", + "our", + "saves", + "slag", + "casualties", + "psychologically", + "transient", + "composure", + "sears", + "tinselling", + "discharging", + "wand", + "foreigners", + "bake", + "gulch", + "clamped", + "restarted", + "elopement", + "spontaneous", + "vaccinates", + "observers", + "highbrow", + "stores", + "blindest", + "periled", + "travelings", + "browns", + "populations", + "entailed", + "advises", + "unlike", + "angled", + "teammates", + "bequest", + "disburses", + "ordinance", + "wider", + "figures", + "decking", + "overwhelmingly", + "satellited", + "piranhas", + "engraving", + "tingles", + "hiatuses", + "presumed", + "toadstool", + "commanders", + "braiding", + "zinced", + "enshrining", + "serviced", + "pampers", + "concentrated", + "collared", + "spinal", + "unilateral", + "covenanting", + "ekes", + "ridden", + "embroidering", + "enlistments", + "tiptoes", + "facade", + "embryonic", + "fault", + "filming", + "Sunday", + "myths", + "chapters", + "lunched", + "workers" +] diff --git a/packages/access-api/src/utils/phrase.js b/packages/access-api/src/utils/phrase.js new file mode 100644 index 000000000..bd42ca9ab --- /dev/null +++ b/packages/access-api/src/utils/phrase.js @@ -0,0 +1,48 @@ +import words from './phrase-words.json' + +/* above can be gathered by hand or e.g. +sudo apt install wamerican-small jq + +numWords=1024 +# HT: https://stackoverflow.com/a/15065490/179583 +shuf -n $numWords <(grep -v \' /usr/share/dict/words) | \ +# HT: https://stackoverflow.com/a/34576956/179583 +jq --raw-input | jq --slurp > src/utils/phrase-words.json +*/ + +// TODO: I can't get this to work, but it'd be better to use this! +// import { randomInt } from 'node:crypto' + +/** + * + * @param {number} max + * @returns {number} + */ +function randomInt(max) { + const array = new Uint32Array(1) + const random = self.crypto.getRandomValues(array)[0] + // 0xFFFFFFFF is the max value of a Uint32 + return Math.floor((random / 0xff_ff_ff_ff) * max) +} + +const DEFAULT_ENTROPY = 50 +const entropyPerWord = Math.log2(words.length) + +function randomWord() { + const randomIdx = randomInt(words.length) + return words[randomIdx] +} + +/** + * + * @param {number} [entropy] + * @returns {string} + */ +export function generateNoncePhrase(entropy = DEFAULT_ENTROPY) { + const phrase = [] + let nWordsNeeded = Math.ceil(entropy / entropyPerWord) + while (nWordsNeeded--) { + phrase.push(randomWord()) + } + return phrase.join(' ') +} diff --git a/packages/access-api/tsconfig.json b/packages/access-api/tsconfig.json index 1facffca5..4b151d9d6 100644 --- a/packages/access-api/tsconfig.json +++ b/packages/access-api/tsconfig.json @@ -6,7 +6,14 @@ "jsx": "react-jsx", "jsxImportSource": "preact" }, - "include": ["src", "scripts", "test", "package.json", "sql"], + "include": [ + "src", + "scripts", + "test", + "sql", + "package.json", + "src/utils/phrase-words.json" + ], "exclude": ["**/node_modules/**"], "references": [ { "path": "../access-client" }, diff --git a/packages/access-client/src/agent.js b/packages/access-client/src/agent.js index 516653236..65a35bc30 100644 --- a/packages/access-client/src/agent.js +++ b/packages/access-client/src/agent.js @@ -335,6 +335,7 @@ export class Agent { * @param {string} email * @param {object} [opts] * @param {AbortSignal} [opts.signal] + * @param {import('./types').ValidationPhraseHandler} [opts.onPhrase] */ async recover(email, opts) { const inv = await this.invokeAndExecute(Space.recoverValidation, { @@ -346,6 +347,10 @@ export class Agent { throw new Error('Recover validation failed', { cause: inv }) } + if (inv?.matchPhrase) { + opts?.onPhrase?.(inv.matchPhrase) + } + const spaceRecover = /** @type {Ucanto.Delegation<[import('./types').SpaceRecover]>} */ ( await this.#waitForDelegation(opts) @@ -433,6 +438,7 @@ export class Agent { * @param {string} email * @param {object} [opts] * @param {AbortSignal} [opts.signal] + * @param {import('./types').ValidationPhraseHandler} [opts.onPhrase] * @param {Ucanto.DID<'key'>} [opts.space] - space to register * @param {Ucanto.DID<'web'>} [opts.provider] - provider to register - defaults to this.connection.id */ @@ -471,6 +477,10 @@ export class Agent { throw new Error('Voucher claim failed', { cause: inv }) } + if (inv?.matchPhrase) { + opts?.onPhrase?.(inv.matchPhrase) + } + const voucherRedeem = /** @type {Ucanto.Delegation<[import('./types').VoucherRedeem]>} */ ( await this.#waitForDelegation(opts) diff --git a/packages/access-client/src/types.ts b/packages/access-client/src/types.ts index e011118a4..31724c18b 100644 --- a/packages/access-client/src/types.ts +++ b/packages/access-client/src/types.ts @@ -29,6 +29,7 @@ import * as Ucanto from '@ucanto/interface' import type { Abilities, + NoncePhrase, SpaceInfo, SpaceRecover, SpaceRecoverValidation, @@ -264,6 +265,8 @@ export type DelegationOptions = SetRequired & { audienceMeta: AgentMeta } +export type ValidationPhraseHandler = (phrase: NoncePhrase) => undefined + /** * Utility types */ diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index 415d40048..2464b0ca2 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -82,6 +82,11 @@ export type StoreList = InferInvokedCapability // Top export type Top = InferInvokedCapability +export type NoncePhrase = string +export interface HasValidationNonce { + phrase: NoncePhrase +} + export type Abilities = TupleToUnion export type AbilitiesArray = [