Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,14 @@ wraps the vault's master bits into a record kept in `localStorage`.

## Features

- **12 secret types** with purpose-built fields: API Token, API Client/Secret, Username and
password, Container Registry, Cloud/Provider, SSH Key, Database, Variables/`.env`,
- **13 secret types** with purpose-built fields: API Token, API Client/Secret, Username and
password, Passkey, Container Registry, Cloud/Provider, SSH Key, Database, Variables/`.env`,
Certificate, Webhook, License and Secure note — plus custom fields on any item.
- **Passkeys are recorded, not stored.** The private half of a passkey never leaves its
authenticator, so no vault can hold it and a web page cannot sign with it. What the type
keeps is what people lose track of: which account, which authenticator (iCloud, Google,
a YubiKey…), which device when it does not sync, and the recovery codes. Search reads the
authenticator and the device, so typing `yubikey` lists exactly what that key opens.
- **46 personal document types**, each with the fields that document actually has:
- *Portugal*: residence permit (per issuance, with issuing entity, process number and
validity), Cartão de Cidadão, NIF, NISS, health service number, criminal record,
Expand Down
43 changes: 43 additions & 0 deletions scripts/smoke-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1926,6 +1926,49 @@ const run = async () => {
await page.click('button:has-text("Desbloquear")');
await page.waitForSelector('text=GitHub PAT', { timeout: 20000 });

/*
* 11c. A passkey, created through the real form.
*
* Last on this page because it adds an item, and every count asserted above
* would move. What it proves is the wiring: the type is offered, its form
* renders, it saves, and the device typed into it is what the real search
* finds — the reason the type exists, since the day someone loses a security
* key, "yubikey" is what they type.
*
* It does NOT prove "only that": this vault holds a single passkey, so a
* keyword on the type would still return one row here. That half lives in
* search.test.ts, which has an iCloud passkey beside the YubiKey one.
*/
await page.click('button:has-text("Novo")');
await page.waitForSelector('text=O que você quer guardar?');
await page.click('button:has-text("Segredo de desenvolvimento")');
await page.waitForSelector('input[placeholder*="Filtrar tipos"]', { timeout: 5000 });
await check('passkey aparece entre os segredos de desenvolvimento', async () =>
(await page.locator('[role="dialog"] button:has-text("Passkey")').count()) === 1);
await page.fill('input[placeholder*="Filtrar tipos"]', 'chave de acesso');
await page.waitForTimeout(200);
await check('o filtro acha a passkey pelo nome que o Google e a Microsoft usam', async () =>
(await page.locator('[role="dialog"] button:has-text("Passkey")').count()) === 1 &&
(await page.locator('[role="dialog"] button:has-text("Chave SSH")').count()) === 0);
await page.click('[role="dialog"] button:has-text("Passkey")');
await page.waitForSelector('text=Guardada em', { timeout: 5000 });
await check('o formulário da passkey traz onde ela vive e como recuperar', async () =>
(await page.locator('[role="dialog"] >> text=Aparelho').count()) >= 1 &&
(await page.locator('[role="dialog"] >> text=Códigos de recuperação').count()) >= 1);
await page.fill('input[aria-label="Nome"]', 'Passkey do GitHub');
await page.locator('label:has-text("Conta") input').first().fill('edgar');
await page.locator('label:has-text("Aparelho") input').first().fill('YubiKey 5C azul');
await page.click('footer button:has-text("Salvar")');
await page.waitForSelector('h2:has-text("Passkey do GitHub")', { timeout: 5000 });

await page.fill('input[type="search"]', 'yubikey');
await page.waitForTimeout(300);
await check('buscar "yubikey" mostra o que essa chave abre, e só isso', async () => {
const rows = await page.locator('main li').allInnerTexts();
return rows.length === 1 && rows[0].includes('Passkey do GitHub') && rows[0].includes('edgar');
});
await page.fill('input[type="search"]', '');

// 12. Mobile: the same vault on a phone-sized, touch-first viewport. A fresh
// page (fresh context) so the flow is seeded from scratch at 375px.
const phone = await browser.newPage({
Expand Down
72 changes: 72 additions & 0 deletions src/lib/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,78 @@ const DEV_TYPE_LIST: BaseTypeDef[] = [
{ id: 'recoveryCodes', label: 'Códigos de recuperação', kind: 'multilineSecret' },
],
},
/*
* A record of a passkey, not the passkey.
*
* The private half of a passkey never leaves its authenticator — that is the
* point of one — so no vault can hold it, and a web page cannot become the
* authenticator that signs with it. What people actually lose track of is
* WHERE each one lives, and what gets them back in when that device is gone.
* So this is that: the account, the authenticator, the device when the key
* does not sync, and the recovery codes.
*
* `provider` and `device` are plain text on purpose. The search indexes
* non-secret fields, so typing "yubikey" lists every account that key opens
* — which is the question someone asks the day they lose it.
*/
{
id: 'passkey',
namePlaceholder: 'Passkey — GitHub',
label: 'Passkey',
description: 'Onde cada passkey está guardada, e como entrar se perder o aparelho.',
icon: 'fingerprint',
accent: '#2dd4bf',
// What each platform calls it in Portuguese, and what a developer types.
// Nothing about WHERE a passkey lives belongs here: a keyword matches every
// item of the type, so "yubikey" in this list would put every passkey in
// the answer to "what does this key open" — the iCloud ones included.
keywords: [
'chave de acesso',
'chaves de acesso',
'chave-senha',
'chaves-senha',
'webauthn',
'fido',
'fido2',
'sem senha',
'passwordless',
],
fields: [
{ id: 'url', label: 'Site', kind: 'url', placeholder: 'https://github.com' },
{ id: 'username', label: 'Conta', kind: 'username', placeholder: 'voce@exemplo.com' },
{
id: 'provider',
label: 'Guardada em',
kind: 'text',
placeholder: 'Chaves do iCloud',
options: [
'Chaves do iCloud',
'Gerenciador de Senhas do Google',
'Windows Hello',
'1Password',
'Bitwarden',
'Dashlane',
'Proton Pass',
'Samsung Pass',
'Chave de segurança (YubiKey…)',
],
},
{
id: 'device',
label: 'Aparelho',
kind: 'text',
placeholder: 'YubiKey 5C · MacBook Pro',
hint: 'Importa quando a passkey não sincroniza: uma chave de segurança, o Windows Hello de um PC.',
},
{ id: 'registeredAt', label: 'Criada em', kind: 'date' },
{
id: 'recoveryCodes',
label: 'Códigos de recuperação',
kind: 'multilineSecret',
hint: 'O que usar para entrar se perder o aparelho onde a passkey vive.',
},
],
},
{
id: 'registry',
namePlaceholder: 'Azure Container Registry — equantic',
Expand Down
42 changes: 42 additions & 0 deletions src/lib/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,45 @@ describe('números com máscara', () => {
expect(matches(item, '98765432100')).toBe(false);
});
});

describe('passkeys', () => {
const onKey = make({
id: 'pk-1',
type: 'passkey',
name: 'GitHub',
fields: {
url: 'https://github.com',
username: 'edgar',
provider: 'Chave de segurança (YubiKey…)',
device: 'YubiKey 5C azul',
recoveryCodes: 'a1b2-c3d4\ne5f6-g7h8',
},
});
const inCloud = make({
id: 'pk-2',
type: 'passkey',
name: 'Google',
fields: { username: 'maria', provider: 'Chaves do iCloud' },
});

it('is found by the name each platform gives it', () => {
// Apple says "chave-senha", Google and Microsoft say "chave de acesso", and a
// developer types the protocol.
for (const term of ['passkey', 'chave de acesso', 'chave-senha', 'webauthn', 'fido2']) {
expect(matches(inCloud, term), term).toBe(true);
}
});

it('answers "what do I lose with this key", and only that', () => {
const found = [onKey, inCloud].filter((item) => matches(item, 'yubikey')).map((item) => item.id);
// Only the one on the key. Were "yubikey" a keyword of the TYPE, every
// passkey would be listed, and the answer to the question would be a lie.
expect(found).toEqual(['pk-1']);
expect([onKey, inCloud].filter((item) => matches(item, 'icloud')).map((item) => item.id)).toEqual(['pk-2']);
});

it('never indexes the recovery codes', () => {
expect(matches(onKey, 'a1b2')).toBe(false);
expect(matches(onKey, 'e5f6-g7h8')).toBe(false);
});
});
Loading