diff --git a/CHANGELOG.md b/CHANGELOG.md index a2fa2c6..105c0a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Each version's section is shown in the in-app update window and on the GitHub re ## Unreleased +### New +- **Share passwords safely.** A protected note now has a share button: pick which fields go and Menew makes an encrypted `.menewsecret` file to drag into Teams, Slack or an email, instead of pasting the password in the chat. Send it to a contact (add their Menew code once, from Notes › Share passwords) and only their Mac can open it, with the sender verified; or protect it with a password Menew generates and pass that on another way. To receive, drop the file on the notch, double-click it or use Import file; it becomes a protected note. Everything is encrypted on your Mac. + ### Fixed - **Codex and the ChatGPT app in the notch.** Codex only runs hooks you approved, and the ChatGPT app (which runs Codex inside) skips unapproved ones without asking, so its sessions never reached the notch. Turning on Codex in AI agents › Set up now also records the approval for Menew's own commands in `~/.codex/config.toml`. If Codex shows as Outdated, press Update once. diff --git a/README.md b/README.md index 9487e7c..145d667 100644 --- a/README.md +++ b/README.md @@ -111,10 +111,23 @@ Give one an abbreviation like `;sig` and typing it in any app turns it into the
-📝 Notes — Many notes with #tags, search and Markdown (checklists included). +📝 Notes — Many notes with #tags, search and Markdown (checklists included), and passwords you can share safely. Protected notes live in the Keychain and open with Touch ID. With Apple Intelligence, a note's ⋯ menu can **summarize it** or **extract its to-dos** as a checklist, on your Mac. **Password notes** keep name and value rows with hidden values, an eye to reveal and a copy button; copied values are marked confidential (no clipboard history) and cleared after 30 seconds. Switch a note's type at any time. +**Share passwords without pasting them in a chat.** Press the share button on a protected note, pick which fields go, and Menew makes an encrypted `.menewsecret` file to drag into Teams, Slack or an email. Send it **to a contact** (they send you their Menew code once; it's public, so chat is fine) and only their Mac can open it, with the sender verified; or **with a password** Menew generates, which you pass on another way (a call, a text, in person). The other person drops the file on the notch, double-clicks it or imports it in Notes › Share passwords, and it becomes a protected note on their Mac. Encryption happens on your Mac (X25519 and AES-GCM, or PBKDF2 with 600,000 rounds for the password); nothing goes over the internet. + +

+ + + Sharing a password note with a contact + + + + Importing a received secret + +

+
@@ -414,7 +427,7 @@ Menew only reads what it shows. With the menu closed it uses about **0.5% CPU, 1 ## Privacy -The app has **no telemetry, no analytics and no account**. Clipboard history and notes are stored locally in `~/Library/Application Support/Menew`. Items marked as passwords by password managers (`org.nspasteboard.ConcealedType`) are never saved. Values copied from password notes are marked the same way and cleared from the clipboard after 30 seconds. Backups are files you save yourself; protected notes are never included in them. The only network calls are the ones you can see: your public IP (`api.ipify.org`), the speed test you start yourself, a currency rate when you press ↩ on a conversion in the command palette (`api.frankfurter.app`, European Central Bank data), and a daily update check against this repo's releases (you can turn it off). Text recognition (copy text from screen or images) uses Apple's Vision framework on your Mac; nothing is uploaded, and the screen capture is deleted right after. AI text actions use only Apple's on-device model (never Private Cloud Compute), so the text stays on your Mac. The next meeting is read from the Calendar app through EventKit, only if you turn it on, and stays on your Mac. Notes you can search in Spotlight are indexed by macOS on your Mac (never protected or password notes, never the clipboard history). Live captions listen only while they are on and never record; speech is recognized and translated on your Mac. Recordings only happen when you start them; the audio, transcript and minutes are kept in `~/Library/Application Support/Menew/recordings`. Memory captures are only taken when you press the shortcut and are kept in `~/Library/Application Support/Menew/memory`; Automatic Memory is off until you turn on a source, reads screenshots, recordings, notes and agent logs on your Mac only (no network) and strips keys and tokens before saving. Widgets read a small file Menew rewrites every minute in its own shared container (`~/Library/Group Containers/9527N6XKKW.com.matheusquost.menew`); they run sandboxed and have no network access. AI agents: usage is read from the logs Claude Code, Codex and Gemini CLI already keep on your Mac (`~/.claude`, `~/.codex`, `~/.gemini`), and the daily totals for the long-term charts stay in `~/Library/Application Support/Menew/agent-usage.json` (no prompts or transcripts); agent hooks and the MCP server talk to Menew through local macOS notifications (if you turn on access for agents in Docker, Menew also listens on `127.0.0.1:47833`, never on your network, and only answers requests carrying a random key you can change), and loaded Ollama and LM Studio models are read from their local ports (`127.0.0.1`). The one optional network call here is **Claude limits**, off by default: when you turn it on, Menew reads the login Claude Code keeps in your Keychain (through the same `security` tool Claude Code uses to save it, so macOS doesn't ask for your password every time it renews) and checks `api.anthropic.com` every 5 minutes for the 5-hour and weekly usage; nothing else is sent. +The app has **no telemetry, no analytics and no account**. Clipboard history and notes are stored locally in `~/Library/Application Support/Menew`. Items marked as passwords by password managers (`org.nspasteboard.ConcealedType`) are never saved. Values copied from password notes are marked the same way and cleared from the clipboard after 30 seconds. Backups are files you save yourself; protected notes are never included in them. Shared passwords are encrypted on your Mac into a file you send yourself; your sharing key stays in the Keychain, and your contacts' public codes in `~/Library/Application Support/Menew/share-contacts.json`. The only network calls are the ones you can see: your public IP (`api.ipify.org`), the speed test you start yourself, a currency rate when you press ↩ on a conversion in the command palette (`api.frankfurter.app`, European Central Bank data), and a daily update check against this repo's releases (you can turn it off). Text recognition (copy text from screen or images) uses Apple's Vision framework on your Mac; nothing is uploaded, and the screen capture is deleted right after. AI text actions use only Apple's on-device model (never Private Cloud Compute), so the text stays on your Mac. The next meeting is read from the Calendar app through EventKit, only if you turn it on, and stays on your Mac. Notes you can search in Spotlight are indexed by macOS on your Mac (never protected or password notes, never the clipboard history). Live captions listen only while they are on and never record; speech is recognized and translated on your Mac. Recordings only happen when you start them; the audio, transcript and minutes are kept in `~/Library/Application Support/Menew/recordings`. Memory captures are only taken when you press the shortcut and are kept in `~/Library/Application Support/Menew/memory`; Automatic Memory is off until you turn on a source, reads screenshots, recordings, notes and agent logs on your Mac only (no network) and strips keys and tokens before saving. Widgets read a small file Menew rewrites every minute in its own shared container (`~/Library/Group Containers/9527N6XKKW.com.matheusquost.menew`); they run sandboxed and have no network access. AI agents: usage is read from the logs Claude Code, Codex and Gemini CLI already keep on your Mac (`~/.claude`, `~/.codex`, `~/.gemini`), and the daily totals for the long-term charts stay in `~/Library/Application Support/Menew/agent-usage.json` (no prompts or transcripts); agent hooks and the MCP server talk to Menew through local macOS notifications (if you turn on access for agents in Docker, Menew also listens on `127.0.0.1:47833`, never on your network, and only answers requests carrying a random key you can change), and loaded Ollama and LM Studio models are read from their local ports (`127.0.0.1`). The one optional network call here is **Claude limits**, off by default: when you turn it on, Menew reads the login Claude Code keeps in your Keychain (through the same `security` tool Claude Code uses to save it, so macOS doesn't ask for your password every time it renews) and checks `api.anthropic.com` every 5 minutes for the 5-hour and weekly usage; nothing else is sent. The website ([menew.web.app](https://menew.web.app/?ref=github)) counts visits and button clicks with [GoatCounter](https://www.goatcounter.com), which uses no cookies and stores no personal data. The app itself never does this. @@ -545,6 +558,7 @@ Recently shipped: - [x] Battery care: charge-level and heat alerts, health history - [x] Menew window with a sidebar, and Memory (capture with source, OCR and AI tags) - [x] Ask your memory in plain words, with sources, and memory in Spotlight +- [x] Share passwords as encrypted files, to a contact or with a password
diff --git a/Resources/Info.plist b/Resources/Info.plist index ab41c60..c77beee 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -20,6 +20,27 @@ CFBundleURLSchemesmenew + + UTExportedTypeDeclarations + + + UTTypeIdentifiercom.matheusquost.menew.secret + UTTypeDescriptionMenew Shared Secret + UTTypeConformsTopublic.data + UTTypeIconSystemGenerated + UTTypeTagSpecification + public.filename-extensionmenewsecret + + + CFBundleDocumentTypes + + + CFBundleTypeNameMenew Shared Secret + CFBundleTypeRoleViewer + LSHandlerRankOwner + LSItemContentTypescom.matheusquost.menew.secret + + NSHighResolutionCapable CFBundleDevelopmentRegionen CFBundleLocalizations diff --git a/Sources/Menew/App.swift b/Sources/Menew/App.swift index af93e99..3641a0a 100644 --- a/Sources/Menew/App.swift +++ b/Sources/Menew/App.swift @@ -224,7 +224,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } } - func popoverShouldClose(_ popover: NSPopover) -> Bool { !notes.authenticating } + func popoverShouldClose(_ popover: NSPopover) -> Bool { !notes.authenticating && !ShareKeys.shared.picking } /// Fechar o menu tranca de novo as notas protegidas func popoverDidClose(_ notification: Notification) { @@ -377,6 +377,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { showPopover() } + /// Abre a tela de importar um segredo compartilhado na aba Notas + func importSecret(_ url: URL) { + ShareKeys.shared.pendingImport = url + nav.tab = .notes + showPopover() + } + /// Resultado do Spotlight: abre a nota escolhida func application(_ application: NSApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([any NSUserActivityRestoring]) -> Void) -> Bool { @@ -393,6 +400,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { /// Toque num widget (menew://system, menew://meetings…): abre o Menew na aba Sistema func application(_ application: NSApplication, open urls: [URL]) { + // Segredo compartilhado (.menewsecret): dois cliques no Finder ou "Abrir com" + if let file = urls.first(where: { $0.isFileURL && $0.pathExtension.lowercased() == SecretShare.fileExtension }) { + importSecret(file) + return + } guard urls.contains(where: { $0.scheme == "menew" }) else { return } nav.tab = .system showPopover() diff --git a/Sources/Menew/L10n.swift b/Sources/Menew/L10n.swift index 7078d43..558693d 100644 --- a/Sources/Menew/L10n.swift +++ b/Sources/Menew/L10n.swift @@ -2001,4 +2001,56 @@ private let table: [String: [String]] = [ "O Menew está em Acessibilidade, mas o macOS não reconhece esta cópia": ["Menew is in Accessibility, but macOS doesn't recognize this copy", "Menew ist unter Bedienungshilfen eingetragen, aber macOS erkennt diese Kopie nicht", "Menew está en Accesibilidad, pero macOS no reconoce esta copia", "Menew 已在辅助功能列表中,但 macOS 不认这个副本"], "Em Ajustes do Sistema › Privacidade e Segurança › Acessibilidade, remova o Menew da lista (botão −) e adicione de novo (botão +). Depois, tente colar outra vez.": ["In System Settings › Privacy & Security › Accessibility, remove Menew from the list (− button) and add it again (+ button). Then try pasting again.", "Entferne Menew unter Systemeinstellungen › Datenschutz & Sicherheit › Bedienungshilfen aus der Liste (Taste −) und füge es wieder hinzu (Taste +). Versuche dann erneut einzufügen.", "En Ajustes del Sistema › Privacidad y seguridad › Accesibilidad, quita Menew de la lista (botón −) y vuelve a añadirlo (botón +). Luego intenta pegar otra vez.", "在“系统设置 › 隐私与安全性 › 辅助功能”中,将 Menew 从列表中移除(− 按钮),再重新添加(+ 按钮),然后再试一次粘贴。"], "Esta cópia é um build de desenvolvimento sem assinatura estável: cada build novo perde a permissão.": ["This copy is a development build without a stable signature: every new build loses the permission.", "Diese Kopie ist ein Entwicklungs-Build ohne feste Signatur: Jeder neue Build verliert die Berechtigung.", "Esta copia es una compilación de desarrollo sin firma estable: cada compilación nueva pierde el permiso.", "这个副本是没有固定签名的开发版本:每次重新构建都会失去该权限。"], + "A nota inteira vai no arquivo.": ["The whole note goes in the file.", "Die ganze Notiz kommt in die Datei.", "La nota entera va en el archivo.", "整条笔记都会放进文件。"], + "Adicionar contato": ["Add contact", "Kontakt hinzufügen", "Añadir contacto", "添加联系人"], + "Adicione o código de quem vai receber senhas suas. Só o Mac da pessoa abre o que você mandar.": ["Add the code of whoever will receive your passwords. Only their Mac can open what you send.", "Füge den Code der Person hinzu, die deine Passwörter bekommt. Nur ihr Mac kann öffnen, was du schickst.", "Añade el código de quien va a recibir tus contraseñas. Solo su Mac puede abrir lo que envíes.", "添加要接收你密码的人的代码。只有对方的 Mac 能打开你发送的内容。"], + "Arraste para o Teams, o Slack ou um e-mail": ["Drag it into Teams, Slack or an email", "Zieh sie in Teams, Slack oder eine E-Mail", "Arrástralo a Teams, Slack o un correo", "拖到 Teams、Slack 或邮件里"], + "Cole o código Menew da pessoa": ["Paste the person's Menew code", "Menew-Code der Person einfügen", "Pega el código Menew de la persona", "粘贴对方的 Menew 代码"], + "Com senha": ["With a password", "Mit Passwort", "Con contraseña", "用密码"], + "Compartilhar com segurança…": ["Share securely…", "Sicher teilen …", "Compartir de forma segura…", "安全分享…"], + "Compartilhar senhas": ["Share passwords", "Passwörter teilen", "Compartir contraseñas", "分享密码"], + "Compartilhar «%@»": ["Share “%@”", "„%@“ teilen", "Compartir «%@»", "分享“%@”"], + "Conferência: %@": ["Check: %@", "Prüfcode: %@", "Verificación: %@", "校验码:%@"], + "Confira com a pessoa: %@": ["Check with the person: %@", "Mit der Person abgleichen: %@", "Compruébalo con la persona: %@", "与对方核对:%@"], + "Contatos": ["Contacts", "Kontakte", "Contactos", "联系人"], + "Copiar código": ["Copy code", "Code kopieren", "Copiar código", "复制代码"], + "Criar arquivo": ["Create file", "Datei erstellen", "Crear archivo", "生成文件"], + "Criptografado no seu Mac. Nada passa pela internet.": ["Encrypted on your Mac. Nothing goes over the internet.", "Auf deinem Mac verschlüsselt. Nichts geht übers Internet.", "Cifrado en tu Mac. Nada pasa por internet.", "在你的 Mac 上加密,不经过互联网。"], + "Código inválido": ["Invalid code", "Ungültiger Code", "Código no válido", "代码无效"], + "De %@ · fora dos seus contatos": ["From %@ · not in your contacts", "Von %@ · nicht in deinen Kontakten", "De %@ · no está en tus contactos", "来自 %@ · 不在你的联系人中"], + "De %@ · verificado": ["From %@ · verified", "Von %@ · bestätigt", "De %@ · verificado", "来自 %@ · 已验证"], + "De alguém fora dos seus contatos": ["From someone not in your contacts", "Von jemandem, der nicht in deinen Kontakten ist", "De alguien que no está en tus contactos", "来自不在你联系人中的人"], + "Descartar": ["Discard", "Verwerfen", "Descartar", "丢弃"], + "Digite a senha que a pessoa te passou.": ["Type the password the person gave you.", "Gib das Passwort ein, das dir die Person gegeben hat.", "Escribe la contraseña que te dio la persona.", "输入对方告诉你的密码。"], + "Enviado por %@": ["Sent by %@", "Gesendet von %@", "Enviado por %@", "由 %@ 发送"], + "Esta nota não tem campos preenchidos.": ["This note has no filled-in fields.", "Diese Notiz hat keine ausgefüllten Felder.", "Esta nota no tiene campos rellenados.", "这条笔记没有已填写的字段。"], + "Este arquivo foi criado para outra pessoa. Só o Mac dela consegue abrir.": ["This file was made for someone else. Only their Mac can open it.", "Diese Datei wurde für jemand anderen erstellt. Nur dessen Mac kann sie öffnen.", "Este archivo se creó para otra persona. Solo su Mac puede abrirlo.", "这个文件是发给别人的,只有对方的 Mac 能打开。"], + "Este arquivo não é um segredo do Menew.": ["This file isn't a Menew secret.", "Diese Datei ist kein Menew-Geheimnis.", "Este archivo no es un secreto de Menew.", "这个文件不是 Menew 密文。"], + "Este arquivo veio de uma versão mais nova do Menew. Atualize para abrir.": ["This file comes from a newer version of Menew. Update to open it.", "Diese Datei stammt aus einer neueren Menew-Version. Aktualisiere, um sie zu öffnen.", "Este archivo viene de una versión más nueva de Menew. Actualiza para abrirlo.", "这个文件来自更新版本的 Menew,请更新后再打开。"], + "Este é o seu próprio código": ["This is your own code", "Das ist dein eigener Code", "Este es tu propio código", "这是你自己的代码"], + "Gerar outra": ["Generate another", "Neues erzeugen", "Generar otra", "重新生成"], + "Importar arquivo…": ["Import file…", "Datei importieren …", "Importar archivo…", "导入文件…"], + "Mande a senha por outro caminho (ligação, SMS ou em pessoa), nunca no mesmo chat do arquivo.": ["Send the password another way (a call, a text or in person), never in the same chat as the file.", "Gib das Passwort auf anderem Weg weiter (Anruf, SMS oder persönlich), nie im selben Chat wie die Datei.", "Pasa la contraseña por otra vía (llamada, SMS o en persona), nunca en el mismo chat que el archivo.", "请通过其他方式(电话、短信或当面)告诉对方密码,不要和文件发在同一个聊天里。"], + "Mande este código para quem vai te enviar senhas. Ele é público: pode ir pelo chat.": ["Send this code to whoever will send you passwords. It's public, so chat is fine.", "Schick diesen Code an alle, die dir Passwörter senden. Er ist öffentlich, der Chat ist also okay.", "Envía este código a quien te vaya a mandar contraseñas. Es público: puede ir por chat.", "把这个代码发给要给你发密码的人。它是公开的,可以通过聊天发送。"], + "Meu código Menew": ["My Menew code", "Mein Menew-Code", "Mi código Menew", "我的 Menew 代码"], + "Nota protegida · %d caracteres": ["Protected note · %d characters", "Geschützte Notiz · %d Zeichen", "Nota protegida · %d caracteres", "受保护的笔记 · %d 个字符"], + "Não deu para criar o arquivo": ["Couldn't create the file", "Die Datei konnte nicht erstellt werden", "No se pudo crear el archivo", "无法生成文件"], + "O arquivo está corrompido ou foi alterado.": ["The file is damaged or was changed.", "Die Datei ist beschädigt oder wurde verändert.", "El archivo está dañado o fue modificado.", "文件已损坏或被修改。"], + "O que vai": ["What to send", "Was gesendet wird", "Qué se envía", "发送内容"], + "Para quem": ["Who it's for", "Für wen", "Para quién", "发给谁"], + "Para um contato": ["To a contact", "An einen Kontakt", "A un contacto", "发给联系人"], + "Peça o código Menew da pessoa e adicione aqui. Só o Mac dela vai conseguir abrir o arquivo.": ["Ask the person for their Menew code and add it here. Only their Mac will be able to open the file.", "Bitte die Person um ihren Menew-Code und füge ihn hier hinzu. Nur ihr Mac kann die Datei dann öffnen.", "Pide a la persona su código Menew y añádelo aquí. Solo su Mac podrá abrir el archivo.", "向对方要 Menew 代码并添加到这里。只有对方的 Mac 能打开这个文件。"], + "Quem receber vai precisar desta senha:": ["The recipient will need this password:", "Die empfangende Person braucht dieses Passwort:", "Quien lo reciba necesitará esta contraseña:", "接收者需要这个密码:"], + "Receber": ["Receive", "Empfangen", "Recibir", "接收"], + "Remover contato": ["Remove contact", "Kontakt entfernen", "Quitar contacto", "移除联系人"], + "Salvar nas notas": ["Save to notes", "In Notizen sichern", "Guardar en notas", "保存到笔记"], + "Salvar nos contatos": ["Save to contacts", "In Kontakten sichern", "Guardar en contactos", "保存到联系人"], + "Segredo": ["Secret", "Geheimnis", "Secreto", "密文"], + "Segredo recebido": ["Received secret", "Empfangenes Geheimnis", "Secreto recibido", "收到的密文"], + "Segredo salvo nas notas": ["Secret saved to notes", "Geheimnis in Notizen gesichert", "Secreto guardado en notas", "密文已保存到笔记"], + "Sem nome": ["Unnamed", "Ohne Namen", "Sin nombre", "未命名"], + "Senha errada": ["Wrong password", "Falsches Passwort", "Contraseña incorrecta", "密码错误"], + "Solte o arquivo .menewsecret no notch, abra com dois cliques ou escolha aqui.": ["Drop the .menewsecret file on the notch, double-click it, or pick it here.", "Lege die .menewsecret-Datei auf die Notch, öffne sie per Doppelklick oder wähle sie hier aus.", "Suelta el archivo .menewsecret en el notch, ábrelo con doble clic o elígelo aquí.", "把 .menewsecret 文件拖到刘海上、双击打开,或在这里选择。"], + "Só %@ consegue abrir este arquivo.": ["Only %@ can open this file.", "Nur %@ kann diese Datei öffnen.", "Solo %@ puede abrir este archivo.", "只有 %@ 能打开这个文件。"], + "Vira uma nota protegida nas Chaves do macOS": ["Becomes a protected note in the macOS Keychain", "Wird zu einer geschützten Notiz im macOS-Schlüsselbund", "Se convierte en una nota protegida en el Llavero de macOS", "会成为 macOS 钥匙串中的受保护笔记"], ] diff --git a/Sources/Menew/NotchHover.swift b/Sources/Menew/NotchHover.swift index fded1f4..98766d7 100644 --- a/Sources/Menew/NotchHover.swift +++ b/Sources/Menew/NotchHover.swift @@ -207,9 +207,10 @@ struct NotchHoverView: View { .background(RoundedRectangle(cornerRadius: 12).strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [5, 4])) .foregroundStyle(model.targeted ? .green : .white.opacity(0.35))) .onDrop(of: [.fileURL, .url, .image, .plainText], isTargeted: $model.targeted) { providers in - let ok = ShelfStore.shared.accept(providers) - if ok { NotchHover.run { app?.shelf.show(nearMouse: false) } } - return ok + // Um .menewsecret abre a tela de importar; o resto vai para a estante, como sempre + ShareKeys.acceptDrop(providers) { rest in + if ShelfStore.shared.accept(rest) { NotchHover.run { app?.shelf.show(nearMouse: false) } } + } } } diff --git a/Sources/Menew/Notes.swift b/Sources/Menew/Notes.swift index a6e522d..21a0508 100644 --- a/Sources/Menew/Notes.swift +++ b/Sources/Menew/Notes.swift @@ -237,6 +237,16 @@ final class NotesStore: ObservableObject { return id } + /// Nota que já nasce protegida (segredo recebido): o texto vai direto para as Chaves, nunca para o arquivo de notas + func createProtected(_ text: String, kind: Note.Kind) -> UUID { + let n = Note(text: Self.firstLine(text), secure: true, kind: kind == .text ? nil : kind) + NoteVault.write(text, id: n.id) + notes.insert(n, at: 0) + unlocked[n.id] = text + save() + return n.id + } + /// Senhas ficam sempre protegidas; voltar para texto mantém a proteção func setKind(_ kind: Note.Kind, for id: UUID) { guard let i = notes.firstIndex(where: { $0.id == id }) else { return } diff --git a/Sources/Menew/NotesViews.swift b/Sources/Menew/NotesViews.swift index f3b7540..0d385e0 100644 --- a/Sources/Menew/NotesViews.swift +++ b/Sources/Menew/NotesViews.swift @@ -10,6 +10,9 @@ struct NotesView: View { @ObservedObject private var recordings = RecordingStore.shared @State private var query = "" @State private var tag: String? + @State private var sharingID: UUID? + @State private var showingShareCenter = false + @ObservedObject private var keys = ShareKeys.shared init(store: NotesStore, nav: PopoverState? = nil, openID: UUID? = nil) { self.store = store @@ -19,9 +22,19 @@ struct NotesView: View { var body: some View { Group { + if let url = keys.pendingImport { + // Segredo recebido (dois cliques, notch, Importar): salvo, a nota nova já abre + ImportSecretView(url: url, store: store) { id in + keys.pendingImport = nil + if let id { showingShareCenter = false; sharingID = nil; openID = id } + } + } else if let id = sharingID, store.note(id) != nil, !store.isLocked(id) { + ShareSecretView(store: store, id: id) { sharingID = nil } + } else if showingShareCenter { + ShareCenterView { showingShareCenter = false } // Uma nota protegida que foi trancada (menu fechado, Mac travado) volta para a lista - if let id = openID, store.note(id) != nil, !store.isLocked(id) { - NoteEditor(store: store, id: id, onTag: { t in tag = t; openID = nil }) { store.lock(id); openID = nil } + } else if let id = openID, store.note(id) != nil, !store.isLocked(id) { + NoteEditor(store: store, id: id, onTag: { t in tag = t; openID = nil }, onShare: { sharingID = id }) { store.lock(id); openID = nil } } else if showingRecordings { RecordingsInNotes { showingRecordings = false } } else { @@ -61,6 +74,7 @@ struct NotesView: View { } .font(.system(size: 13)) .glassField() + IconButton(icon: "person.badge.key.fill", help: L("Compartilhar senhas")) { showingShareCenter = true } IconButton(icon: "key.fill", help: L("Nova nota de senhas")) { openID = store.createPasswords() } PillButton(title: L("Nova"), icon: "square.and.pencil", primary: true) { openID = store.create(text: tag.map { "\n\n#\($0)" } ?? "") @@ -160,6 +174,11 @@ struct NotesView: View { .buttonStyle(.plain) .contextMenu { Button(L("Copiar")) { copy(n) } + if n.secure { + Button(L("Compartilhar com segurança…")) { + Task { if await store.unlock(n.id) { sharingID = n.id } } + } + } if !n.isPasswords { Button(n.secure ? L("Remover proteção") : L("Proteger com Touch ID")) { Task { @@ -192,6 +211,7 @@ private struct NoteEditor: View { @ObservedObject var store: NotesStore let id: UUID var onTag: (String) -> Void + var onShare: () -> Void var onClose: () -> Void @AppStorage("notesReading") private var reading = false @@ -212,6 +232,9 @@ private struct NoteEditor: View { } Text(note?.title ?? "").font(.system(size: 13, weight: .semibold)).lineLimit(1) Spacer() + if note?.secure == true { + IconButton(icon: "square.and.arrow.up", help: L("Compartilhar com segurança…"), action: onShare) + } if note?.isPasswords != true { GlassSegmented(selection: $reading, options: [ .init(value: false, icon: "pencil", help: L("Editar")), @@ -224,6 +247,9 @@ private struct NoteEditor: View { NSPasteboard.general.clearContents() NSPasteboard.general.setString(store.text(of: id), forType: .string) } + if note?.secure == true { + Button(L("Compartilhar com segurança…"), action: onShare) + } Picker(L("Tipo"), selection: Binding(get: { note?.kind ?? .text }, set: { store.setKind($0, for: id) })) { ForEach(Note.Kind.allCases, id: \.self) { k in Label(k.title, systemImage: k.icon).tag(k) } } diff --git a/Sources/Menew/SecretShare.swift b/Sources/Menew/SecretShare.swift new file mode 100644 index 0000000..82fead1 --- /dev/null +++ b/Sources/Menew/SecretShare.swift @@ -0,0 +1,390 @@ +import AppKit +import CommonCrypto +import CryptoKit +import Foundation +import Security +import UniformTypeIdentifiers + +// Compartilhar senhas sem mandar o texto cru pelo chat: o Menew gera um arquivo `.menewsecret` criptografado. +// +// Dois modos: +// - Para um contato: cada Menew tem um par de chaves X25519 (a privada fica nas Chaves do macOS). A pessoa manda +// o "código Menew" dela, que é a chave pública, e o arquivo só abre no Mac dela. O remetente também assina com a +// chave dele (dois acordos de chave, como no Noise), então quem recebe sabe de quem veio. +// - Com senha: uma senha gerada pelo Menew (≈79 bits), passada por outro caminho (ligação, SMS, em pessoa). +// PBKDF2-SHA256 com 600 mil rodadas deriva a chave. +// Nos dois, o conteúdo vai em AES-GCM e o cabeçalho entra como dado autenticado. Nada passa pela rede. + +/// O que vai dentro do arquivo, já descriptografado +struct SecretPayload: Codable, Equatable { + /// Texto da nota no formato de sempre: título na primeira linha e, nas senhas, um "chave: valor" por linha + var text: String + var kind: Note.Kind + /// Nome de quem mandou, como está no Mac dele (só informativo; quem confirma é a chave) + var sender: String? + var created = Date() + + var title: String { String(text.split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false).first ?? "") } + var fieldNames: [String] { kind == .passwords ? SecretField.parse(text).fields.map(\.key) : [] } +} + +/// O arquivo como fica no disco (JSON) +struct SecretEnvelope: Codable, Equatable { + enum Mode: String, Codable { case contact, passphrase } + + var menew = "secret" + var v = 1 + var mode: Mode + /// Modo contato: chaves públicas de quem mandou e de quem recebe, e a chave efêmera deste arquivo + var from: String? + var to: String? + var eph: String? + /// Modo senha + var salt: String? + var rounds: Int? + /// AES-GCM (nonce + texto + tag) + var box: String + + /// Tudo do cabeçalho entra como dado autenticado: trocar um campo invalida o arquivo + var aad: Data { + Data(["menew-secret", String(v), mode.rawValue, from ?? "", to ?? "", eph ?? "", salt ?? "", String(rounds ?? 0)] + .joined(separator: "|").utf8) + } +} + +enum SecretShareError: Error, Equatable { + case notASecret, newerVersion, notForMe, wrongPassphrase, damaged +} + +enum SecretShare { + static let fileExtension = "menewsecret" + static let fileType = UTType(filenameExtension: fileExtension) ?? .data + static let defaultRounds = 600_000 + private static let keyPrefix = "menew-key:" + + // MARK: Códigos (chaves públicas) + + static func code(for key: Curve25519.KeyAgreement.PublicKey) -> String { + keyPrefix + base64url(key.rawRepresentation) + } + + /// Acha um código num texto colado ("Ana Silva · menew-key:…"); o que vem antes vira a sugestão de nome + static func parseCode(_ text: String) -> (key: Curve25519.KeyAgreement.PublicKey, name: String)? { + guard let r = text.range(of: keyPrefix + "[A-Za-z0-9_-]{43}", options: .regularExpression), + let raw = unbase64url(String(text[r].dropFirst(keyPrefix.count))), + let key = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: raw) else { return nil } + let name = text[.. String { + let hex = SHA256.hash(data: raw).prefix(6).map { String(format: "%02X", $0) }.joined() + return stride(from: 0, to: hex.count, by: 4).map { i in + let a = hex.index(hex.startIndex, offsetBy: i) + return String(hex[a.. String { + let alphabet = Array("abcdefghjkmnpqrstuvwxyz23456789") + var rng = SystemRandomNumberGenerator() + let chars = (0..<16).map { _ in alphabet[Int.random(in: 0.. String { + passphrase.lowercased().filter { !$0.isWhitespace && $0 != "-" } + } + + // MARK: Fechar + + static func seal(_ secret: SecretPayload, from sender: Curve25519.KeyAgreement.PrivateKey, + to recipient: Curve25519.KeyAgreement.PublicKey) throws -> SecretEnvelope { + let eph = Curve25519.KeyAgreement.PrivateKey() + var env = SecretEnvelope(mode: .contact, from: base64url(sender.publicKey.rawRepresentation), + to: base64url(recipient.rawRepresentation), eph: base64url(eph.publicKey.rawRepresentation), box: "") + let key = try contactKey(eph.sharedSecretFromKeyAgreement(with: recipient), + sender.sharedSecretFromKeyAgreement(with: recipient), aad: env.aad) + env.box = try box(secret, key: key, aad: env.aad) + return env + } + + static func seal(_ secret: SecretPayload, passphrase: String, rounds: Int = defaultRounds) throws -> SecretEnvelope { + var salt = Data(count: 16) + _ = salt.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } + var env = SecretEnvelope(mode: .passphrase, salt: base64url(salt), rounds: rounds, box: "") + guard let key = pbkdf2(normalize(passphrase), salt: salt, rounds: rounds) else { throw SecretShareError.damaged } + env.box = try box(secret, key: key, aad: env.aad) + return env + } + + // MARK: Abrir + + static func decode(_ data: Data) throws -> SecretEnvelope { + guard let env = try? JSONDecoder().decode(SecretEnvelope.self, from: data), env.menew == "secret" else { + throw SecretShareError.notASecret + } + guard env.v == 1 else { throw SecretShareError.newerVersion } + return env + } + + /// Modo contato. Devolve também a chave pública de quem mandou, já comprovada pela própria abertura. + static func open(_ env: SecretEnvelope, with me: Curve25519.KeyAgreement.PrivateKey) throws + -> (secret: SecretPayload, sender: Curve25519.KeyAgreement.PublicKey) { + guard env.mode == .contact, let fromRaw = env.from.flatMap(unbase64url), let ephRaw = env.eph.flatMap(unbase64url), + let toRaw = env.to.flatMap(unbase64url), + let from = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: fromRaw), + let eph = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: ephRaw) else { throw SecretShareError.damaged } + guard toRaw == me.publicKey.rawRepresentation else { throw SecretShareError.notForMe } + let key = try contactKey(me.sharedSecretFromKeyAgreement(with: eph), me.sharedSecretFromKeyAgreement(with: from), aad: env.aad) + return (try unbox(env.box, key: key, aad: env.aad, failure: .damaged), from) + } + + static func open(_ env: SecretEnvelope, passphrase: String) throws -> SecretPayload { + guard env.mode == .passphrase, let salt = env.salt.flatMap(unbase64url), let rounds = env.rounds, + (10_000...10_000_000).contains(rounds), + let key = pbkdf2(normalize(passphrase), salt: salt, rounds: rounds) else { throw SecretShareError.damaged } + return try unbox(env.box, key: key, aad: env.aad, failure: .wrongPassphrase) + } + + // MARK: Criptografia + + private static func contactKey(_ a: CryptoKit.SharedSecret, _ b: CryptoKit.SharedSecret, aad: Data) throws -> SymmetricKey { + var ikm = a.withUnsafeBytes { Data($0) } + ikm += b.withUnsafeBytes { Data($0) } + return HKDF.deriveKey(inputKeyMaterial: SymmetricKey(data: ikm), salt: aad, + info: Data("menew-secret contact".utf8), outputByteCount: 32) + } + + private static func box(_ secret: SecretPayload, key: SymmetricKey, aad: Data) throws -> String { + let enc = JSONEncoder() + enc.dateEncodingStrategy = .iso8601 + let sealed = try AES.GCM.seal(try enc.encode(secret), using: key, authenticating: aad) + guard let combined = sealed.combined else { throw SecretShareError.damaged } + return base64url(combined) + } + + private static func unbox(_ box: String, key: SymmetricKey, aad: Data, failure: SecretShareError) throws -> SecretPayload { + guard let data = unbase64url(box), let sealed = try? AES.GCM.SealedBox(combined: data) else { throw SecretShareError.damaged } + guard let plain = try? AES.GCM.open(sealed, using: key, authenticating: aad) else { throw failure } + let dec = JSONDecoder() + dec.dateDecodingStrategy = .iso8601 + guard let secret = try? dec.decode(SecretPayload.self, from: plain) else { throw SecretShareError.damaged } + return secret + } + + static func pbkdf2(_ passphrase: String, salt: Data, rounds: Int) -> SymmetricKey? { + let pw = Array(passphrase.utf8) + guard !pw.isEmpty else { return nil } + var out = [UInt8](repeating: 0, count: 32) + let status = salt.withUnsafeBytes { s in + pw.withUnsafeBufferPointer { p in + p.baseAddress!.withMemoryRebound(to: CChar.self, capacity: max(pw.count, 1)) { cp in + CCKeyDerivationPBKDF(CCPBKDFAlgorithm(kCCPBKDF2), cp, pw.count, + s.bindMemory(to: UInt8.self).baseAddress, salt.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), UInt32(rounds), &out, out.count) + } + } + } + return status == kCCSuccess ? SymmetricKey(data: out) : nil + } + + static func base64url(_ data: Data) -> String { + data.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + static func unbase64url(_ s: String) -> Data? { + var b = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + while b.count % 4 != 0 { b += "=" } + return Data(base64Encoded: b) + } +} + +// MARK: Chave deste Mac e contatos + +struct ShareContact: Codable, Identifiable, Equatable { + var id = UUID() + var name: String + /// Chave pública em base64url + var key: String + var added = Date() + + var publicKey: Curve25519.KeyAgreement.PublicKey? { + SecretShare.unbase64url(key).flatMap { try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: $0) } + } + var fingerprint: String { SecretShare.unbase64url(key).map(SecretShare.fingerprint) ?? "" } +} + +@MainActor +final class ShareKeys: ObservableObject { + static let shared = ShareKeys() + + @Published private(set) var contacts: [ShareContact] = [] + /// Arquivo aberto (dois cliques, solto no notch): a aba Notas mostra a tela de importar + @Published var pendingImport: URL? + private var demoKey: Curve25519.KeyAgreement.PrivateKey? + + private var url: URL { AppData.dir.appendingPathComponent("share-contacts.json") } + private static let service = "com.matheusquost.menew.share" + + private init() { + if let data = try? Data(contentsOf: url), let list = try? JSONDecoder().decode([ShareContact].self, from: data) { + contacts = list + } + } + + /// Chave deste Mac: criada na primeira vez, guardada nas Chaves do macOS, nunca sai daqui + var identity: Curve25519.KeyAgreement.PrivateKey { + if let demoKey { return demoKey } + if let cachedKey { return cachedKey } + let key = loadOrCreateIdentity() + cachedKey = key + return key + } + private var cachedKey: Curve25519.KeyAgreement.PrivateKey? + + private func loadOrCreateIdentity() -> Curve25519.KeyAgreement.PrivateKey { + let q: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: Self.service, + kSecAttrAccount as String: "identity"] + var read = q + read[kSecReturnData as String] = true + read[kSecMatchLimit as String] = kSecMatchLimitOne + var out: AnyObject? + if SecItemCopyMatching(read as CFDictionary, &out) == errSecSuccess, let data = out as? Data, + let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: data) { + return key + } + let key = Curve25519.KeyAgreement.PrivateKey() + var add = q + add[kSecValueData as String] = key.rawRepresentation + add[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + add[kSecAttrLabel as String] = "Menew sharing key" + SecItemAdd(add as CFDictionary, nil) + return key + } + + var myCode: String { SecretShare.code(for: identity.publicKey) } + var myFingerprint: String { SecretShare.fingerprint(identity.publicKey.rawRepresentation) } + /// O que vai para a área de transferência: com o nome, quem cola já vê de quem é + var myCodeToCopy: String { "\(NSFullUserName()) · \(myCode)" } + + func contact(for key: Curve25519.KeyAgreement.PublicKey) -> ShareContact? { + let k = SecretShare.base64url(key.rawRepresentation) + return contacts.first { $0.key == k } + } + + @discardableResult + func add(name: String, key: Curve25519.KeyAgreement.PublicKey) -> ShareContact { + let k = SecretShare.base64url(key.rawRepresentation) + let clean = name.trimmingCharacters(in: .whitespacesAndNewlines) + if let i = contacts.firstIndex(where: { $0.key == k }) { + if !clean.isEmpty { contacts[i].name = clean } + save() + return contacts[i] + } + let c = ShareContact(name: clean.isEmpty ? SecretShare.fingerprint(key.rawRepresentation) : clean, key: k) + contacts.append(c) + contacts.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + save() + return c + } + + func remove(_ id: UUID) { + contacts.removeAll { $0.id == id } + save() + } + + private func save() { + guard demoKey == nil, let data = try? JSONEncoder().encode(contacts) else { return } + try? FileManager.default.createDirectory(at: AppData.dir, withIntermediateDirectories: true) + try? data.write(to: url, options: .atomic) + } + + /// Para as capturas de tela: chave e contatos fictícios, nada lido nem gravado + func showDemo() { + // Chaves fixas (derivadas de um texto): as capturas saem iguais a cada vez + func key(_ seed: String) -> Curve25519.KeyAgreement.PrivateKey { + (try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: Data(SHA256.hash(data: Data(("menew-demo " + seed).utf8))))) + ?? Curve25519.KeyAgreement.PrivateKey() + } + demoKey = key("me") + let names = L10n.current == .pt ? ["Ana Souza", "Bruno Lima", "Equipe de infra"] : ["Ana Miller", "Ben Carter", "Infra team"] + contacts = names.enumerated().map { i, n in ShareContact(name: n, key: SecretShare.base64url(key(String(i)).publicKey.rawRepresentation)) } + } + + /// Arquivo de exemplo para a captura da tela de importar: mandado por "Ana" para este Mac + func demoFile() -> URL? { + guard let me = demoKey, let ana = contacts.first else { return nil } + let from = (try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: Data(SHA256.hash(data: Data("menew-demo 0".utf8))))) + let text = SecretField.format(title: "Grafana", fields: [SecretField(key: L("Usuário"), value: "admin"), + SecretField(key: L("Senha"), value: "demo-only-password")]) + guard let from, let env = try? SecretShare.seal(SecretPayload(text: text, kind: .passwords, sender: ana.name), + from: from, to: me.publicKey) else { return nil } + return try? write(env, title: "Grafana") + } + + // MARK: Arquivo + + /// Grava o arquivo numa pasta temporária, pronto para arrastar ou compartilhar. + /// Os arquivos de antes são apagados: só sobra o último. + func write(_ env: SecretEnvelope, title: String) throws -> URL { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("Menew Share", isDirectory: true) + try? FileManager.default.removeItem(at: dir) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let bad = CharacterSet(charactersIn: "/\\:?%*|\"<>\n\r\t") + var name = title.components(separatedBy: bad).joined(separator: " ").trimmingCharacters(in: .whitespaces) + if name.isEmpty { name = L("Segredo") } + let file = dir.appendingPathComponent(String(name.prefix(60))).appendingPathExtension(SecretShare.fileExtension) + let enc = JSONEncoder() + enc.outputFormatting = [.prettyPrinted, .sortedKeys] + try enc.encode(env).write(to: file, options: .atomic) + return file + } + + /// "Importar arquivo…": o menu não fecha enquanto o painel está aberto + func pickFile() -> URL? { + let panel = NSOpenPanel() + panel.allowedContentTypes = [SecretShare.fileType] + panel.allowsMultipleSelection = false + NSApp.activate(ignoringOtherApps: true) + picking = true + defer { picking = false } + return panel.runModal() == .OK ? panel.url : nil + } + private(set) var picking = false + + /// Arquivos soltos no notch: os `.menewsecret` vão para a tela de importar, o resto para a estante. + /// `rest` recebe os itens que não são segredos. + static func acceptDrop(_ providers: [NSItemProvider], rest: @escaping @MainActor ([NSItemProvider]) -> Void) -> Bool { + let files = providers.filter { $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) } + guard !files.isEmpty else { rest(providers); return true } + let group = DispatchGroup() + let lock = NSLock() + var secrets: [Int: URL] = [:] + for (i, p) in providers.enumerated() where files.contains(p) { + group.enter() + _ = p.loadObject(ofClass: URL.self) { url, _ in + lock.lock() + if let url, url.pathExtension.lowercased() == SecretShare.fileExtension { secrets[i] = url } + lock.unlock() + group.leave() + } + } + group.notify(queue: .main) { + MainActor.assumeIsolated { + if let first = secrets.sorted(by: { $0.key < $1.key }).first?.value { + (NSApp.delegate as? AppDelegate)?.importSecret(first) + } + let plain = providers.enumerated().filter { secrets[$0.offset] == nil }.map(\.element) + if !plain.isEmpty { rest(plain) } + } + } + return true + } +} diff --git a/Sources/Menew/SecretShareViews.swift b/Sources/Menew/SecretShareViews.swift new file mode 100644 index 0000000..f0f8be8 --- /dev/null +++ b/Sources/Menew/SecretShareViews.swift @@ -0,0 +1,602 @@ +import AppKit +import CryptoKit +import SwiftUI + +// Telas do compartilhamento de senhas, dentro da aba Notas: mandar, receber, e o código e os contatos. + +/// Mandar uma nota protegida: escolher os campos e para quem, e sai um arquivo `.menewsecret` para arrastar +struct ShareSecretView: View { + @ObservedObject var store: NotesStore + let id: UUID + var onClose: () -> Void + @ObservedObject private var keys = ShareKeys.shared + + enum Mode: Hashable { case contact, passphrase } + + @State private var mode: Mode = .contact + @State private var title = "" + @State private var fields: [SecretField] = [] + @State private var included: Set = [] + @State private var recipient: UUID? + @State private var passphrase = SecretShare.generatePassphrase() + @State private var adding = false + @State private var working = false + @State private var file: URL? + @State private var failed = false + + init(store: NotesStore, id: UUID, recipient: UUID? = nil, mode: Mode? = nil, onClose: @escaping () -> Void) { + self.store = store + self.id = id + self.onClose = onClose + _recipient = State(initialValue: recipient) + _forcedMode = State(initialValue: mode) + } + @State private var forcedMode: Mode? + + private var isPasswords: Bool { store.note(id)?.isPasswords == true } + private var chosen: ShareContact? { keys.contacts.first { $0.id == recipient } } + + var body: some View { + VStack(spacing: 8) { + HStack(spacing: 8) { + IconButton(icon: "chevron.left", help: L("Voltar"), action: onClose) + Image(systemName: "paperplane.fill").font(.system(size: 11)).foregroundStyle(Theme.violet) + Text(L("Compartilhar «%@»", title)).font(.system(size: 13, weight: .semibold)).lineLimit(1) + Spacer() + } + ScrollView { + VStack(spacing: 10) { + if let file { result(file) } else { what; who } + } + .padding(.bottom, 4) + } + .noLiquidGlass() + .scrollIndicators(.never) + HStack(spacing: 6) { + Image(systemName: "lock.shield").foregroundStyle(.green) + Text(L("Criptografado no seu Mac. Nada passa pela internet.")).foregroundStyle(.secondary) + Spacer() + if file == nil { + if working { ProgressView().controlSize(.small) } + PillButton(title: L("Criar arquivo"), icon: "lock.doc.fill", primary: true, action: create) + .disabled(!ready || working).opacity(ready ? 1 : 0.5) + } else { + PillButton(title: L("Pronto"), primary: true, action: onClose) + } + } + .font(.system(size: 11)) + } + .onAppear(perform: load) + } + + private var ready: Bool { + (!isPasswords || !included.isEmpty) && (mode == .passphrase || chosen?.publicKey != nil) + } + + private func load() { + let text = store.text(of: id) + let parsed = SecretField.parse(text) + title = parsed.title.isEmpty ? (store.note(id)?.title ?? "") : parsed.title + if isPasswords { + fields = parsed.fields.filter { !$0.value.isEmpty } + included = Set(fields.map(\.id)) + } + if let forcedMode { mode = forcedMode } else if keys.contacts.isEmpty { mode = .passphrase } + if recipient == nil, keys.contacts.count == 1 { recipient = keys.contacts[0].id } + } + + // O que vai + @ViewBuilder private var what: some View { + VStack(alignment: .leading, spacing: 6) { + section(L("O que vai")) + if isPasswords { + if fields.isEmpty { + Text(L("Esta nota não tem campos preenchidos.")).font(.system(size: 12)).foregroundStyle(.secondary) + } + ForEach(fields) { f in + Toggle(isOn: Binding(get: { included.contains(f.id) }, + set: { if $0 { included.insert(f.id) } else { included.remove(f.id) } })) { + HStack(spacing: 6) { + Text(f.key.isEmpty ? L("Sem nome") : f.key).font(.system(size: 12.5, weight: .medium)).lineLimit(1) + Spacer(minLength: 6) + Text(String(repeating: "•", count: min(max(f.value.count, 4), 10))) + .font(.system(size: 11, design: .monospaced)).foregroundStyle(.tertiary) + } + } + .toggleStyle(.checkbox) + } + } else { + Text(L("A nota inteira vai no arquivo.")).font(.system(size: 12)).foregroundStyle(.secondary) + } + } + .card(padding: 12) + } + + // Para quem + private var who: some View { + VStack(alignment: .leading, spacing: 8) { + section(L("Para quem")) + GlassSegmented(selection: $mode, options: [ + .init(value: .contact, title: L("Para um contato"), icon: "person.fill"), + .init(value: .passphrase, title: L("Com senha"), icon: "key.horizontal.fill"), + ]) + if mode == .contact { contactPicker } else { passphraseBox } + } + .card(padding: 12) + } + + @ViewBuilder private var contactPicker: some View { + if keys.contacts.isEmpty && !adding { + Text(L("Peça o código Menew da pessoa e adicione aqui. Só o Mac dela vai conseguir abrir o arquivo.")) + .font(.system(size: 11.5)).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + ForEach(keys.contacts) { c in + Button { recipient = c.id } label: { + HStack(spacing: 8) { + Image(systemName: recipient == c.id ? "largecircle.fill.circle" : "circle") + .foregroundStyle(recipient == c.id ? Theme.violet : .secondary) + Text(c.name).font(.system(size: 12.5, weight: .medium)).lineLimit(1) + Spacer(minLength: 6) + Text(c.fingerprint).font(.system(size: 10, design: .monospaced)).foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + if adding { + AddContactForm(onAdded: { c in recipient = c.id; adding = false }, onCancel: { adding = false }) + } else { + Button { adding = true } label: { + Label(L("Adicionar contato"), systemImage: "plus.circle.fill") + .font(.system(size: 12, weight: .medium)).foregroundStyle(Theme.violet) + } + .buttonStyle(.plain) + } + } + + private var passphraseBox: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 4) { + Text(passphrase).font(.system(size: 16, weight: .semibold, design: .monospaced)).textSelection(.enabled) + Spacer(minLength: 4) + smallButton("arrow.clockwise", help: L("Gerar outra")) { passphrase = SecretShare.generatePassphrase() } + smallButton("doc.on.doc", help: L("Copiar")) { SecretCopy.copy(passphrase) } + } + warning(L("Mande a senha por outro caminho (ligação, SMS ou em pessoa), nunca no mesmo chat do arquivo.")) + } + } + + private func result(_ file: URL) -> some View { + VStack(spacing: 12) { + VStack(spacing: 8) { + Image(systemName: "lock.doc.fill").font(.system(size: 40)).foregroundStyle(Theme.violet.gradient) + Text(file.lastPathComponent).font(.system(size: 12.5, weight: .semibold)).lineLimit(1).truncationMode(.middle) + Text(L("Arraste para o Teams, o Slack ou um e-mail")).font(.system(size: 11)).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(style: StrokeStyle(lineWidth: 1.2, dash: [5, 4])).foregroundStyle(.secondary.opacity(0.5))) + .contentShape(Rectangle()) + .onDrag { NSItemProvider(object: file as NSURL) } + .help(L("Arraste para o Teams, o Slack ou um e-mail")) + + HStack(spacing: 8) { + ShareLink(item: file) { + Label(L("Compartilhar…"), systemImage: "square.and.arrow.up").font(.system(size: 12, weight: .semibold)) + } + PillButton(title: L("Mostrar no Finder"), icon: "folder") { + NSWorkspace.shared.activateFileViewerSelecting([file]) + } + } + + if mode == .contact, let chosen { + Label(L("Só %@ consegue abrir este arquivo.", chosen.name), systemImage: "person.badge.key.fill") + .font(.system(size: 12)).foregroundStyle(.secondary).frame(maxWidth: .infinity, alignment: .leading) + } else { + VStack(alignment: .leading, spacing: 6) { + Text(L("Quem receber vai precisar desta senha:")).font(.system(size: 12)).foregroundStyle(.secondary) + HStack { + Text(passphrase).font(.system(size: 16, weight: .semibold, design: .monospaced)).textSelection(.enabled) + Spacer() + smallButton("doc.on.doc", help: L("Copiar")) { SecretCopy.copy(passphrase) } + } + warning(L("Mande a senha por outro caminho (ligação, SMS ou em pessoa), nunca no mesmo chat do arquivo.")) + } + .card(padding: 12) + } + if failed { warning(L("Não deu para criar o arquivo")) } + } + } + + private func create() { + let text = isPasswords + ? SecretField.format(title: title, fields: fields.filter { included.contains($0.id) }) + : store.text(of: id) + let payload = SecretPayload(text: text, kind: isPasswords ? .passwords : .text, sender: NSFullUserName()) + let me = keys.identity + let to = chosen?.publicKey + let mode = mode, pass = passphrase, title = title + working = true + failed = false + Task { + // 600 mil rodadas de PBKDF2 levam um instante: fora da thread principal + let env = await Task.detached { () -> SecretEnvelope? in + if mode == .contact, let to { return try? SecretShare.seal(payload, from: me, to: to) } + return try? SecretShare.seal(payload, passphrase: pass) + }.value + working = false + guard let env, let url = try? keys.write(env, title: title) else { failed = true; return } + withAnimation(.spring(duration: 0.3)) { file = url } + } + } +} + +/// O código deste Mac, os contatos e o botão de importar +struct ShareCenterView: View { + var onClose: () -> Void + @ObservedObject private var keys = ShareKeys.shared + @State private var adding = false + @State private var copied = false + @State private var hovering: UUID? + + var body: some View { + VStack(spacing: 8) { + HStack(spacing: 8) { + IconButton(icon: "chevron.left", help: L("Voltar"), action: onClose) + Text(L("Compartilhar senhas")).font(.system(size: 13, weight: .semibold)) + Spacer() + } + ScrollView { + VStack(spacing: 10) { + myCode + contacts + receive + } + .padding(.bottom, 4) + } + .noLiquidGlass() + .scrollIndicators(.never) + } + } + + private var myCode: some View { + VStack(alignment: .leading, spacing: 8) { + section(L("Meu código Menew")) + Text(L("Mande este código para quem vai te enviar senhas. Ele é público: pode ir pelo chat.")) + .font(.system(size: 11.5)).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text(keys.myCode).font(.system(size: 11, design: .monospaced)).lineLimit(1).truncationMode(.middle) + Text(L("Conferência: %@", keys.myFingerprint)).font(.system(size: 10.5, design: .monospaced)).foregroundStyle(.tertiary) + } + Spacer(minLength: 4) + PillButton(title: copied ? L("Copiado") : L("Copiar código"), icon: copied ? "checkmark" : "doc.on.doc", primary: true) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(keys.myCodeToCopy, forType: .string) + copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) { copied = false } + } + } + } + .card(padding: 12) + } + + private var contacts: some View { + VStack(alignment: .leading, spacing: 8) { + section(L("Contatos")) + if keys.contacts.isEmpty && !adding { + Text(L("Adicione o código de quem vai receber senhas suas. Só o Mac da pessoa abre o que você mandar.")) + .font(.system(size: 11.5)).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + ForEach(keys.contacts) { c in + HStack(spacing: 8) { + Image(systemName: "person.crop.circle.fill").font(.system(size: 18)).foregroundStyle(Theme.violet.opacity(0.8)) + VStack(alignment: .leading, spacing: 1) { + Text(c.name).font(.system(size: 12.5, weight: .medium)).lineLimit(1) + Text(c.fingerprint).font(.system(size: 10, design: .monospaced)).foregroundStyle(.tertiary) + } + Spacer() + if hovering == c.id { + Button { keys.remove(c.id) } label: { + Image(systemName: "trash").font(.system(size: 12)).foregroundStyle(.secondary) + } + .buttonStyle(.plain).help(L("Remover contato")) + } + } + .contentShape(Rectangle()) + .onHover { hovering = $0 ? c.id : (hovering == c.id ? nil : hovering) } + .contextMenu { Button(L("Remover contato"), role: .destructive) { keys.remove(c.id) } } + } + if adding { + AddContactForm(onAdded: { _ in adding = false }, onCancel: { adding = false }) + } else { + Button { adding = true } label: { + Label(L("Adicionar contato"), systemImage: "plus.circle.fill") + .font(.system(size: 12, weight: .medium)).foregroundStyle(Theme.violet) + } + .buttonStyle(.plain) + } + } + .card(padding: 12) + } + + private var receive: some View { + VStack(alignment: .leading, spacing: 8) { + section(L("Receber")) + Text(L("Solte o arquivo .menewsecret no notch, abra com dois cliques ou escolha aqui.")) + .font(.system(size: 11.5)).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + PillButton(title: L("Importar arquivo…"), icon: "tray.and.arrow.down") { + if let url = keys.pickFile() { keys.pendingImport = url } + } + } + .card(padding: 12) + } +} + +/// Colar o código de alguém: o nome vem junto quando a pessoa copiou pelo Menew +struct AddContactForm: View { + var onAdded: (ShareContact) -> Void + var onCancel: () -> Void + @ObservedObject private var keys = ShareKeys.shared + @State private var code = "" + @State private var name = "" + @FocusState private var focused: Bool + + private var parsed: (key: Curve25519.KeyAgreement.PublicKey, name: String)? { SecretShare.parseCode(code) } + private var isMine: Bool { parsed?.key.rawRepresentation == keys.identity.publicKey.rawRepresentation } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + TextField(L("Cole o código Menew da pessoa"), text: $code) + .textFieldStyle(.plain).font(.system(size: 12, design: .monospaced)).glassField() + .focused($focused) + TextField(L("Nome"), text: $name).textFieldStyle(.plain).font(.system(size: 12.5)).glassField() + if let parsed { + if isMine { + warning(L("Este é o seu próprio código")) + } else { + Text(L("Confira com a pessoa: %@", SecretShare.fingerprint(parsed.key.rawRepresentation))) + .font(.system(size: 11, design: .monospaced)).foregroundStyle(.secondary) + } + } else if !code.isEmpty { + warning(L("Código inválido")) + } + HStack { + Spacer() + PillButton(title: L("Cancelar"), action: onCancel) + PillButton(title: L("Adicionar"), icon: "person.badge.plus", primary: true) { + guard let parsed, !isMine else { return } + onAdded(keys.add(name: name, key: parsed.key)) + } + .disabled(parsed == nil || isMine).opacity(parsed == nil || isMine ? 0.5 : 1) + } + } + .padding(10) + .background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color.primary.opacity(0.045))) + .onAppear { + // Código copiado agora há pouco: já vem colado + if let s = NSPasteboard.general.string(forType: .string), SecretShare.parseCode(s) != nil { code = s } + focused = code.isEmpty + } + .onChange(of: code) { + guard let p = parsed else { return } + if name.isEmpty, !p.name.isEmpty { name = p.name } + // Fica só o código no campo; o nome foi para o outro + let clean = SecretShare.code(for: p.key) + if code != clean { code = clean } + } + } +} + +/// Arquivo recebido: abre (com a chave deste Mac ou a senha), mostra o que tem e salva como nota protegida +struct ImportSecretView: View { + let url: URL + @ObservedObject var store: NotesStore + var onDone: (UUID?) -> Void + @ObservedObject private var keys = ShareKeys.shared + + @State private var env: SecretEnvelope? + @State private var problem: SecretShareError? + @State private var secret: SecretPayload? + @State private var sender: Curve25519.KeyAgreement.PublicKey? + @State private var passphrase = "" + @State private var opening = false + @State private var saveContact = true + @State private var contactName = "" + @FocusState private var focused: Bool + + private var known: ShareContact? { sender.flatMap { keys.contact(for: $0) } } + + var body: some View { + VStack(spacing: 8) { + HStack(spacing: 8) { + IconButton(icon: "chevron.left", help: L("Descartar")) { onDone(nil) } + Image(systemName: "lock.doc.fill").font(.system(size: 11)).foregroundStyle(Theme.violet) + Text(L("Segredo recebido")).font(.system(size: 13, weight: .semibold)) + Spacer() + } + ScrollView { + VStack(spacing: 10) { + HStack(spacing: 8) { + Image(systemName: "doc.fill").foregroundStyle(.secondary) + Text(url.lastPathComponent).font(.system(size: 12)).lineLimit(1).truncationMode(.middle) + Spacer() + } + .card(padding: 10) + content + } + .padding(.bottom, 4) + } + .noLiquidGlass() + .scrollIndicators(.never) + if secret != nil { + HStack { + Image(systemName: "lock.shield").foregroundStyle(.orange) + Text(L("Vira uma nota protegida nas Chaves do macOS")).foregroundStyle(.secondary) + Spacer() + PillButton(title: L("Salvar nas notas"), icon: "square.and.arrow.down", primary: true, action: save) + } + .font(.system(size: 11)) + } + } + .onAppear(perform: read) + .onChange(of: url) { read() } + } + + @ViewBuilder private var content: some View { + if let problem, problem != .wrongPassphrase { + VStack(spacing: 8) { + Image(systemName: problem == .notForMe ? "person.crop.circle.badge.xmark" : "exclamationmark.triangle.fill") + .font(.system(size: 28)).foregroundStyle(.orange) + Text(message(problem)).font(.system(size: 12.5)).multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity).card(padding: 16) + } else if let secret { + opened(secret) + } else if env?.mode == .passphrase { + VStack(alignment: .leading, spacing: 8) { + section(L("Senha")) + Text(L("Digite a senha que a pessoa te passou.")).font(.system(size: 12)).foregroundStyle(.secondary) + HStack(spacing: 8) { + TextField("xxxx-xxxx-xxxx-xxxx", text: $passphrase) + .textFieldStyle(.plain).font(.system(size: 14, design: .monospaced)).glassField() + .focused($focused).onSubmit(openWithPassphrase) + if opening { ProgressView().controlSize(.small) } + PillButton(title: L("Abrir"), icon: "lock.open.fill", primary: true, action: openWithPassphrase) + .disabled(passphrase.isEmpty || opening) + } + if problem == .wrongPassphrase { warning(L("Senha errada")) } + } + .card(padding: 12) + .onAppear { focused = true } + } + } + + private func opened(_ s: SecretPayload) -> some View { + VStack(spacing: 10) { + VStack(alignment: .leading, spacing: 8) { + fromLine(s) + Text(s.title.isEmpty ? L("Nota sem título") : s.title).font(.system(size: 15, weight: .semibold)) + if s.kind == .passwords { + ForEach(Array(s.fieldNames.enumerated()), id: \.offset) { _, name in + HStack { + Text(name).font(.system(size: 12, weight: .medium)).foregroundStyle(.secondary) + Spacer() + Text("••••••••").font(.system(size: 11, design: .monospaced)).foregroundStyle(.tertiary) + } + .padding(.horizontal, 10).padding(.vertical, 7) + .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Color.primary.opacity(0.045))) + } + } else { + Text(L("Nota protegida · %d caracteres", s.text.count)).font(.system(size: 12)).foregroundStyle(.secondary) + } + } + .card(padding: 12) + // Remetente fora dos contatos: dá para guardar para responder depois + if env?.mode == .contact, known == nil, let sender { + VStack(alignment: .leading, spacing: 6) { + Toggle(L("Salvar nos contatos"), isOn: $saveContact).toggleStyle(.checkbox).font(.system(size: 12)) + if saveContact { + TextField(L("Nome"), text: $contactName).textFieldStyle(.plain).font(.system(size: 12.5)).glassField() + } + Text(L("Conferência: %@", SecretShare.fingerprint(sender.rawRepresentation))) + .font(.system(size: 10.5, design: .monospaced)).foregroundStyle(.tertiary) + } + .card(padding: 12) + } + } + } + + @ViewBuilder private func fromLine(_ s: SecretPayload) -> some View { + if env?.mode == .contact { + if let known { + Label(L("De %@ · verificado", known.name), systemImage: "checkmark.seal.fill") + .font(.system(size: 11.5, weight: .medium)).foregroundStyle(.green) + } else { + Label(s.sender.map { L("De %@ · fora dos seus contatos", $0) } ?? L("De alguém fora dos seus contatos"), + systemImage: "questionmark.circle.fill") + .font(.system(size: 11.5, weight: .medium)).foregroundStyle(.orange) + } + } else if let name = s.sender, !name.isEmpty { + Label(L("Enviado por %@", name), systemImage: "key.horizontal.fill") + .font(.system(size: 11.5, weight: .medium)).foregroundStyle(.secondary) + } + } + + private func message(_ e: SecretShareError) -> String { + switch e { + case .notASecret: L("Este arquivo não é um segredo do Menew.") + case .newerVersion: L("Este arquivo veio de uma versão mais nova do Menew. Atualize para abrir.") + case .notForMe: L("Este arquivo foi criado para outra pessoa. Só o Mac dela consegue abrir.") + case .damaged: L("O arquivo está corrompido ou foi alterado.") + case .wrongPassphrase: L("Senha errada") + } + } + + private func read() { + env = nil; secret = nil; sender = nil; problem = nil; passphrase = "" + let access = url.startAccessingSecurityScopedResource() + defer { if access { url.stopAccessingSecurityScopedResource() } } + guard let data = try? Data(contentsOf: url) else { problem = .notASecret; return } + do { + let e = try SecretShare.decode(data) + env = e + if e.mode == .contact { + let r = try SecretShare.open(e, with: keys.identity) + secret = r.secret + sender = r.sender + contactName = r.secret.sender ?? "" + } + } catch let e as SecretShareError { + problem = e + } catch { + problem = .damaged + } + } + + private func openWithPassphrase() { + guard let env, !passphrase.isEmpty, !opening else { return } + opening = true + problem = nil + let pass = passphrase + Task { + let r = await Task.detached { () -> Result in + do { return .success(try SecretShare.open(env, passphrase: pass)) } catch { return .failure(error as? SecretShareError ?? .damaged) } + }.value + opening = false + switch r { + case .success(let s): withAnimation(.spring(duration: 0.3)) { secret = s } + case .failure(let e): problem = e + } + } + } + + private func save() { + guard let secret else { return } + if env?.mode == .contact, known == nil, saveContact, let sender { keys.add(name: contactName, key: sender) } + let id = store.createProtected(secret.text, kind: secret.kind) + HUD.show(icon: "checkmark.circle.fill", color: .green, text: L("Segredo salvo nas notas")) + onDone(id) + } +} + +// MARK: Pedaços em comum + +private func section(_ title: String) -> some View { + Text(title.uppercased()).font(.system(size: 10, weight: .semibold)).foregroundStyle(.secondary).tracking(0.4) +} + +private func warning(_ text: String) -> some View { + Label(text, systemImage: "exclamationmark.triangle.fill") + .font(.system(size: 11)).foregroundStyle(.orange).fixedSize(horizontal: false, vertical: true) +} + +private func smallButton(_ icon: String, help: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: icon).font(.system(size: 12, weight: .medium)).foregroundStyle(.secondary) + .frame(width: 26, height: 22).contentShape(Rectangle()) + } + .buttonStyle(.plain).help(help).accessibilityLabel(help) +} diff --git a/Sources/Menew/Snapshot.swift b/Sources/Menew/Snapshot.swift index bd19d04..2ea4ce4 100644 --- a/Sources/Menew/Snapshot.swift +++ b/Sources/Menew/Snapshot.swift @@ -134,6 +134,34 @@ enum Snapshot { .padding(.top, 14).frame(width: 400, height: 600).background(Theme.windowFill), size: NSSize(width: 400, height: 600), dark: dark, to: dir.appendingPathComponent("note-passwords-\(suffix).png")) + // Compartilhar senhas: mandar a nota de senhas para um contato, e o código e os contatos + ShareKeys.shared.showDemo() + if let wifi = notes.notes.first(where: \.isPasswords) { + render(ShareSecretView(store: notes, id: wifi.id, recipient: ShareKeys.shared.contacts.first?.id, onClose: {}) + .padding(.top, 14).padding(.horizontal, 14).padding(.bottom, 14) + .frame(width: 400, height: 600).background(Theme.windowFill), + size: NSSize(width: 400, height: 600), dark: dark, + to: dir.appendingPathComponent("share-secret-\(suffix).png")) + } + if let wifi = notes.notes.first(where: \.isPasswords) { + render(ShareSecretView(store: notes, id: wifi.id, mode: .passphrase, onClose: {}) + .padding(.top, 14).padding(.horizontal, 14).padding(.bottom, 14) + .frame(width: 400, height: 600).background(Theme.windowFill), + size: NSSize(width: 400, height: 600), dark: dark, + to: dir.appendingPathComponent("share-passphrase-\(suffix).png")) + } + if let file = ShareKeys.shared.demoFile() { + render(ImportSecretView(url: file, store: NotesStore.demo(), onDone: { _ in }) + .padding(.top, 14).padding(.horizontal, 14).padding(.bottom, 14) + .frame(width: 400, height: 600).background(Theme.windowFill), + size: NSSize(width: 400, height: 600), dark: dark, + to: dir.appendingPathComponent("share-import-\(suffix).png")) + } + render(ShareCenterView(onClose: {}) + .padding(.top, 14).padding(.horizontal, 14).padding(.bottom, 14) + .frame(width: 400, height: 600).background(Theme.windowFill), + size: NSSize(width: 400, height: 600), dark: dark, + to: dir.appendingPathComponent("share-center-\(suffix).png")) render(ScreenTimeCard().padding(14).frame(width: 400, alignment: .top).background(Theme.windowFill), size: NSSize(width: 400, height: 640), dark: dark, to: dir.appendingPathComponent("screentime-\(suffix).png")) render(SystemHistoryCard().padding(14).frame(width: 400, alignment: .top).background(Theme.windowFill), diff --git a/Tests/MenewTests/SecretShareTests.swift b/Tests/MenewTests/SecretShareTests.swift new file mode 100644 index 0000000..6ec1da3 --- /dev/null +++ b/Tests/MenewTests/SecretShareTests.swift @@ -0,0 +1,68 @@ +import CryptoKit +import XCTest +@testable import Menew + +// Compartilhar senhas: o arquivo abre só com a chave certa ou a senha certa, e qualquer alteração é recusada. + +final class SecretShareTests: XCTestCase { + private let payload = SecretPayload(text: "Grafana\nUsuário: admin\nSenha: s3cr:et", kind: .passwords, sender: "Ana") + + private func roundTrip(_ env: SecretEnvelope) throws -> SecretEnvelope { + try SecretShare.decode(try JSONEncoder().encode(env)) + } + + func testContactModeOpensOnlyForTheRecipient() throws { + let ana = Curve25519.KeyAgreement.PrivateKey(), bruno = Curve25519.KeyAgreement.PrivateKey() + let env = try roundTrip(try SecretShare.seal(payload, from: ana, to: bruno.publicKey)) + let (secret, sender) = try SecretShare.open(env, with: bruno) + XCTAssertEqual(secret.text, payload.text) + XCTAssertEqual(secret.fieldNames, ["Usuário", "Senha"]) + XCTAssertEqual(sender.rawRepresentation, ana.publicKey.rawRepresentation) + XCTAssertThrowsError(try SecretShare.open(env, with: Curve25519.KeyAgreement.PrivateKey())) { + XCTAssertEqual($0 as? SecretShareError, .notForMe) + } + } + + func testContactModeRejectsAForgedSender() throws { + let ana = Curve25519.KeyAgreement.PrivateKey(), bruno = Curve25519.KeyAgreement.PrivateKey() + var env = try SecretShare.seal(payload, from: ana, to: bruno.publicKey) + // Trocar o remetente por outra chave não passa: a chave derivada muda e o cabeçalho é autenticado + env.from = SecretShare.base64url(Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation) + XCTAssertThrowsError(try SecretShare.open(env, with: bruno)) { XCTAssertEqual($0 as? SecretShareError, .damaged) } + } + + func testPassphraseModeIgnoresCaseSpacesAndDashes() throws { + let pass = SecretShare.generatePassphrase() + XCTAssertEqual(pass.count, 19) + XCTAssertTrue(pass.allSatisfy { "abcdefghjkmnpqrstuvwxyz23456789-".contains($0) }) + let env = try roundTrip(try SecretShare.seal(payload, passphrase: pass, rounds: 20_000)) + let typed = pass.uppercased().replacingOccurrences(of: "-", with: " ") + XCTAssertEqual(try SecretShare.open(env, passphrase: typed).text, payload.text) + XCTAssertThrowsError(try SecretShare.open(env, passphrase: "aaaa-bbbb-cccc-dddd")) { + XCTAssertEqual($0 as? SecretShareError, .wrongPassphrase) + } + } + + func testTamperedHeaderIsRefused() throws { + var env = try SecretShare.seal(payload, passphrase: "abcd-efgh-jkmn-pqrs", rounds: 20_000) + env.rounds = 30_000 + XCTAssertThrowsError(try SecretShare.open(env, passphrase: "abcd-efgh-jkmn-pqrs")) + } + + func testDecodeRejectsOtherFiles() { + XCTAssertThrowsError(try SecretShare.decode(Data("{\"a\":1}".utf8))) { XCTAssertEqual($0 as? SecretShareError, .notASecret) } + let newer = Data(#"{"menew":"secret","v":2,"mode":"contact","box":""}"#.utf8) + XCTAssertThrowsError(try SecretShare.decode(newer)) { XCTAssertEqual($0 as? SecretShareError, .newerVersion) } + } + + func testParseCodeKeepsTheNameThatCameWithIt() throws { + let key = Curve25519.KeyAgreement.PrivateKey().publicKey + let code = SecretShare.code(for: key) + let parsed = try XCTUnwrap(SecretShare.parseCode("Ana Souza · \(code)")) + XCTAssertEqual(parsed.key.rawRepresentation, key.rawRepresentation) + XCTAssertEqual(parsed.name, "Ana Souza") + XCTAssertEqual(SecretShare.parseCode(code)?.name, "") + XCTAssertNil(SecretShare.parseCode("menew-key:curto")) + XCTAssertEqual(SecretShare.fingerprint(key.rawRepresentation).count, 14) + } +} diff --git a/docs/assets/de/note-passwords-dark.png b/docs/assets/de/note-passwords-dark.png index 7cd9f9e..1175673 100644 Binary files a/docs/assets/de/note-passwords-dark.png and b/docs/assets/de/note-passwords-dark.png differ diff --git a/docs/assets/de/note-passwords-light.png b/docs/assets/de/note-passwords-light.png index a11ce7f..6793b84 100644 Binary files a/docs/assets/de/note-passwords-light.png and b/docs/assets/de/note-passwords-light.png differ diff --git a/docs/assets/de/popover-notes-dark.png b/docs/assets/de/popover-notes-dark.png index 5d4b45c..0c26488 100644 Binary files a/docs/assets/de/popover-notes-dark.png and b/docs/assets/de/popover-notes-dark.png differ diff --git a/docs/assets/de/popover-notes-dark.webp b/docs/assets/de/popover-notes-dark.webp index 969794c..9e890d2 100644 Binary files a/docs/assets/de/popover-notes-dark.webp and b/docs/assets/de/popover-notes-dark.webp differ diff --git a/docs/assets/de/popover-notes-light.png b/docs/assets/de/popover-notes-light.png index 75d7c91..b05d188 100644 Binary files a/docs/assets/de/popover-notes-light.png and b/docs/assets/de/popover-notes-light.png differ diff --git a/docs/assets/de/popover-notes-light.webp b/docs/assets/de/popover-notes-light.webp index 4f3f720..0360601 100644 Binary files a/docs/assets/de/popover-notes-light.webp and b/docs/assets/de/popover-notes-light.webp differ diff --git a/docs/assets/es/note-passwords-dark.png b/docs/assets/es/note-passwords-dark.png index 3ab4d68..937da0d 100644 Binary files a/docs/assets/es/note-passwords-dark.png and b/docs/assets/es/note-passwords-dark.png differ diff --git a/docs/assets/es/note-passwords-light.png b/docs/assets/es/note-passwords-light.png index c8a9908..f3843b3 100644 Binary files a/docs/assets/es/note-passwords-light.png and b/docs/assets/es/note-passwords-light.png differ diff --git a/docs/assets/es/popover-notes-dark.png b/docs/assets/es/popover-notes-dark.png index 1a5a5d5..7c9ea14 100644 Binary files a/docs/assets/es/popover-notes-dark.png and b/docs/assets/es/popover-notes-dark.png differ diff --git a/docs/assets/es/popover-notes-dark.webp b/docs/assets/es/popover-notes-dark.webp index 9818efa..c0c8869 100644 Binary files a/docs/assets/es/popover-notes-dark.webp and b/docs/assets/es/popover-notes-dark.webp differ diff --git a/docs/assets/es/popover-notes-light.png b/docs/assets/es/popover-notes-light.png index a309ca7..cd37e3b 100644 Binary files a/docs/assets/es/popover-notes-light.png and b/docs/assets/es/popover-notes-light.png differ diff --git a/docs/assets/es/popover-notes-light.webp b/docs/assets/es/popover-notes-light.webp index 1bf2169..7be21fa 100644 Binary files a/docs/assets/es/popover-notes-light.webp and b/docs/assets/es/popover-notes-light.webp differ diff --git a/docs/assets/note-passwords-dark.png b/docs/assets/note-passwords-dark.png index a3159ed..f52d7d9 100644 Binary files a/docs/assets/note-passwords-dark.png and b/docs/assets/note-passwords-dark.png differ diff --git a/docs/assets/note-passwords-light.png b/docs/assets/note-passwords-light.png index 863109e..f34329c 100644 Binary files a/docs/assets/note-passwords-light.png and b/docs/assets/note-passwords-light.png differ diff --git a/docs/assets/popover-notes-dark.png b/docs/assets/popover-notes-dark.png index accd424..e3953a3 100644 Binary files a/docs/assets/popover-notes-dark.png and b/docs/assets/popover-notes-dark.png differ diff --git a/docs/assets/popover-notes-dark.webp b/docs/assets/popover-notes-dark.webp index 938015d..9634d55 100644 Binary files a/docs/assets/popover-notes-dark.webp and b/docs/assets/popover-notes-dark.webp differ diff --git a/docs/assets/popover-notes-light.png b/docs/assets/popover-notes-light.png index 21bb925..6f127d6 100644 Binary files a/docs/assets/popover-notes-light.png and b/docs/assets/popover-notes-light.png differ diff --git a/docs/assets/popover-notes-light.webp b/docs/assets/popover-notes-light.webp index e72aa84..52f0aa5 100644 Binary files a/docs/assets/popover-notes-light.webp and b/docs/assets/popover-notes-light.webp differ diff --git a/docs/assets/pt/note-passwords-dark.png b/docs/assets/pt/note-passwords-dark.png index e6cbd42..22cc1f1 100644 Binary files a/docs/assets/pt/note-passwords-dark.png and b/docs/assets/pt/note-passwords-dark.png differ diff --git a/docs/assets/pt/note-passwords-light.png b/docs/assets/pt/note-passwords-light.png index b4008d2..470f7b5 100644 Binary files a/docs/assets/pt/note-passwords-light.png and b/docs/assets/pt/note-passwords-light.png differ diff --git a/docs/assets/pt/popover-notes-dark.png b/docs/assets/pt/popover-notes-dark.png index c8aefdb..ce235c4 100644 Binary files a/docs/assets/pt/popover-notes-dark.png and b/docs/assets/pt/popover-notes-dark.png differ diff --git a/docs/assets/pt/popover-notes-dark.webp b/docs/assets/pt/popover-notes-dark.webp index ead813c..720880d 100644 Binary files a/docs/assets/pt/popover-notes-dark.webp and b/docs/assets/pt/popover-notes-dark.webp differ diff --git a/docs/assets/pt/popover-notes-light.png b/docs/assets/pt/popover-notes-light.png index 94981e0..bda33b4 100644 Binary files a/docs/assets/pt/popover-notes-light.png and b/docs/assets/pt/popover-notes-light.png differ diff --git a/docs/assets/pt/popover-notes-light.webp b/docs/assets/pt/popover-notes-light.webp index bd3c928..5310513 100644 Binary files a/docs/assets/pt/popover-notes-light.webp and b/docs/assets/pt/popover-notes-light.webp differ diff --git a/docs/assets/share-import-dark.png b/docs/assets/share-import-dark.png new file mode 100644 index 0000000..d8914fa Binary files /dev/null and b/docs/assets/share-import-dark.png differ diff --git a/docs/assets/share-import-light.png b/docs/assets/share-import-light.png new file mode 100644 index 0000000..dd71bca Binary files /dev/null and b/docs/assets/share-import-light.png differ diff --git a/docs/assets/share-secret-dark.png b/docs/assets/share-secret-dark.png new file mode 100644 index 0000000..14baff1 Binary files /dev/null and b/docs/assets/share-secret-dark.png differ diff --git a/docs/assets/share-secret-light.png b/docs/assets/share-secret-light.png new file mode 100644 index 0000000..02c641b Binary files /dev/null and b/docs/assets/share-secret-light.png differ diff --git a/docs/assets/zh/note-passwords-dark.png b/docs/assets/zh/note-passwords-dark.png index f06e907..591e66f 100644 Binary files a/docs/assets/zh/note-passwords-dark.png and b/docs/assets/zh/note-passwords-dark.png differ diff --git a/docs/assets/zh/note-passwords-light.png b/docs/assets/zh/note-passwords-light.png index db7e220..f9658ac 100644 Binary files a/docs/assets/zh/note-passwords-light.png and b/docs/assets/zh/note-passwords-light.png differ diff --git a/docs/assets/zh/popover-notes-dark.png b/docs/assets/zh/popover-notes-dark.png index 05acb01..b528d20 100644 Binary files a/docs/assets/zh/popover-notes-dark.png and b/docs/assets/zh/popover-notes-dark.png differ diff --git a/docs/assets/zh/popover-notes-dark.webp b/docs/assets/zh/popover-notes-dark.webp index e93dfa5..519f9a0 100644 Binary files a/docs/assets/zh/popover-notes-dark.webp and b/docs/assets/zh/popover-notes-dark.webp differ diff --git a/docs/assets/zh/popover-notes-light.png b/docs/assets/zh/popover-notes-light.png index 176f6d7..4ea1a4f 100644 Binary files a/docs/assets/zh/popover-notes-light.png and b/docs/assets/zh/popover-notes-light.png differ diff --git a/docs/assets/zh/popover-notes-light.webp b/docs/assets/zh/popover-notes-light.webp index ea48236..fa92b40 100644 Binary files a/docs/assets/zh/popover-notes-light.webp and b/docs/assets/zh/popover-notes-light.webp differ diff --git a/docs/de/index.html b/docs/de/index.html index 5a4fc09..b8f6b4e 100644 --- a/docs/de/index.html +++ b/docs/de/index.html @@ -24,7 +24,7 @@ - + @@ -583,7 +583,7 @@

Internet

Notizen, Textbausteine & Geräte

-

Notizen mit #Tags und Markdown-Checklisten, geschützte Notizen mit Touch ID, Passwortnotizen mit verborgenen Feldern, die du mit einem Klick kopierst, und wiederverwendbare Textbausteine in ⌘⇧V.

Alles anzeigen
  • Gib einem Textbaustein ein Kürzel wie „;sig“ – tippst du es irgendwo, wird es zum Text.
  • Textbausteine können das Datum einsetzen, vor dem Einfügen nach einem Namen fragen und den Cursor platzieren.
+

Notizen mit #Tags und Markdown-Checklisten, geschützte Notizen mit Touch ID, Passwortnotizen mit verborgenen Feldern, die du mit einem Klick kopierst, und wiederverwendbare Textbausteine in ⌘⇧V.

Alles anzeigen
  • Teile ein Passwort als verschlüsselte Datei, die nur der Mac deines Kontakts öffnet, statt es in einen Chat zu kopieren.
  • Gib einem Textbaustein ein Kürzel wie „;sig“ – tippst du es irgendwo, wird es zum Text.
  • Textbausteine können das Datum einsetzen, vor dem Einfügen nach einem Namen fragen und den Cursor platzieren.
diff --git a/docs/es/index.html b/docs/es/index.html index 51d9cde..c62f0f9 100644 --- a/docs/es/index.html +++ b/docs/es/index.html @@ -24,7 +24,7 @@ - + @@ -583,7 +583,7 @@

Internet

Notas, fragmentos y dispositivos

-

Notas con #etiquetas y listas de tareas en Markdown, notas protegidas que se abren con Touch ID, notas de contraseñas con campos ocultos que copias con un clic y fragmentos reutilizables en ⌘⇧V.

Ver todo
  • Dale a un fragmento una abreviatura como “;firma” y al escribirla en cualquier app se convierte en el texto.
  • Los fragmentos pueden rellenar la fecha, preguntar un nombre antes de pegar y dejar el cursor donde quieras.
+

Notas con #etiquetas y listas de tareas en Markdown, notas protegidas que se abren con Touch ID, notas de contraseñas con campos ocultos que copias con un clic y fragmentos reutilizables en ⌘⇧V.

Ver todo
  • Comparte una contraseña en un archivo cifrado que solo el Mac de tu contacto puede abrir, en lugar de pegarla en un chat.
  • Dale a un fragmento una abreviatura como “;firma” y al escribirla en cualquier app se convierte en el texto.
  • Los fragmentos pueden rellenar la fecha, preguntar un nombre antes de pegar y dejar el cursor donde quieras.
diff --git a/docs/index.html b/docs/index.html index 3fc7ce9..10da3fd 100644 --- a/docs/index.html +++ b/docs/index.html @@ -34,7 +34,7 @@ })(); - + @@ -593,7 +593,7 @@

Internet

Notes, snippets & devices

-

Notes with #tags and Markdown checklists, protected notes that open with Touch ID, password notes with hidden fields you copy in one click, and reusable snippets in ⌘⇧V.

See everything
  • Give a snippet an abbreviation like “;sig” and typing it anywhere turns it into the text.
  • Snippets can fill in the date, ask for a name before pasting and place the cursor where you want.
+

Notes with #tags and Markdown checklists, protected notes that open with Touch ID, password notes with hidden fields you copy in one click, and reusable snippets in ⌘⇧V.

See everything
  • Share a password as an encrypted file that only your contact's Mac can open, instead of pasting it in a chat.
  • Give a snippet an abbreviation like “;sig” and typing it anywhere turns it into the text.
  • Snippets can fill in the date, ask for a name before pasting and place the cursor where you want.
diff --git a/docs/llms.txt b/docs/llms.txt index 8b0f554..3ce1fa8 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -2,7 +2,7 @@ > Free, open source Mac menu bar app: command palette, clipboard history, windows, screenshots, meeting notes, screen memory and on-device AI. No account. -Menew 1.103.0 (updated 2026-11-12). Native Swift/SwiftUI app for macOS 14 or later, Apple Silicon and Intel. Free and open source (GPL-3.0). Signed with an Apple Developer ID and notarized. Made by Quost. +Menew 1.103.0 (updated 2026-11-13). Native Swift/SwiftUI app for macOS 14 or later, Apple Silicon and Intel. Free and open source (GPL-3.0). Signed with an Apple Developer ID and notarized. Made by Quost. ## Highlights @@ -55,7 +55,7 @@ Menew (also written Menew for Mac) is the macOS app by Quost at github.com/Quost - Quick actions: Brightness for every screen, external monitors included. Mute the mic or the sound, hide the Dock, dark mode, keep awake with a timer, hidden files and desktop icons. Plus color picker, screenshots you can annotate, blur and pin on screen (⌃⌥⌘S), eject disks and more. Keep Awake also works until a set time or while an app is open. Switch the sound output and microphone (AirPods, headphones, speakers) in one click, and turn on presentation mode before sharing your screen. Record the screen as video or GIF, or take a scrolling capture of a whole page. The notch comes alive too: a meeting about to start with a Join button, recording time, timers and a muted mic show up next to the camera. In clipboard images and pinned screenshots, select text or click links and phone numbers (Live Text). And remove the background from a photo in one click. Take a photo or scan a document with your iPhone and it lands right on the clipboard, on the shelf, or as text. - Free up space: Clear caches, logs and developer junk in seconds. A deep scan finds old downloads, large files and duplicate files for you to review. Removed files go to the Trash. - Internet: Live download and upload, local and public IP, and a real speed test powered by macOS itself. See which apps are using the network right now. -- Notes, snippets & devices: Notes with #tags and Markdown checklists, protected notes that open with Touch ID, password notes with hidden fields you copy in one click, and reusable snippets in ⌘⇧V. Give a snippet an abbreviation like “;sig” and typing it anywhere turns it into the text. Snippets can fill in the date, ask for a name before pasting and place the cursor where you want. +- Notes, snippets & devices: Notes with #tags and Markdown checklists, protected notes that open with Touch ID, password notes with hidden fields you copy in one click, and reusable snippets in ⌘⇧V. Share a password as an encrypted file that only your contact's Mac can open, instead of pasting it in a chat. Give a snippet an abbreviation like “;sig” and typing it anywhere turns it into the text. Snippets can fill in the date, ask for a name before pasting and place the cursor where you want. ## More features diff --git a/docs/pt/index.html b/docs/pt/index.html index 7c54662..487c1df 100644 --- a/docs/pt/index.html +++ b/docs/pt/index.html @@ -24,7 +24,7 @@ - + @@ -583,7 +583,7 @@

Internet

Notas, snippets e dispositivos

-

Notas com #tags e checklists em Markdown, notas protegidas que abrem com Touch ID, notas de senhas com campos escondidos que você copia com um clique e snippets reutilizáveis no ⌘⇧V.

Ver tudo
  • Dê a um snippet uma abreviação como “;ass” e, ao digitá-la em qualquer app, ela vira o texto.
  • Snippets podem preencher a data, perguntar um nome antes de colar e deixar o cursor onde você quiser.
+

Notas com #tags e checklists em Markdown, notas protegidas que abrem com Touch ID, notas de senhas com campos escondidos que você copia com um clique e snippets reutilizáveis no ⌘⇧V.

Ver tudo
  • Compartilhe uma senha num arquivo criptografado que só o Mac do seu contato abre, em vez de colar no chat.
  • Dê a um snippet uma abreviação como “;ass” e, ao digitá-la em qualquer app, ela vira o texto.
  • Snippets podem preencher a data, perguntar um nome antes de colar e deixar o cursor onde você quiser.
diff --git a/docs/sitemap.xml b/docs/sitemap.xml index cb35e81..24ac4fa 100644 --- a/docs/sitemap.xml +++ b/docs/sitemap.xml @@ -2,7 +2,7 @@ https://menew.web.app/ - 2026-11-12 + 2026-11-13 @@ -11,7 +11,7 @@ https://menew.web.app/pt - 2026-11-12 + 2026-11-13 @@ -20,7 +20,7 @@ https://menew.web.app/de - 2026-11-12 + 2026-11-13 @@ -29,7 +29,7 @@ https://menew.web.app/es - 2026-11-12 + 2026-11-13 @@ -38,7 +38,7 @@ https://menew.web.app/zh - 2026-11-12 + 2026-11-13 @@ -47,30 +47,30 @@ https://menew.web.app/vs - 2026-11-12 + 2026-11-13 https://menew.web.app/vs/raycast - 2026-11-12 + 2026-11-13 https://menew.web.app/vs/maccy - 2026-11-12 + 2026-11-13 https://menew.web.app/vs/rectangle - 2026-11-12 + 2026-11-13 https://menew.web.app/vs/istat-menus - 2026-11-12 + 2026-11-13 https://menew.web.app/vs/cleanshot - 2026-11-12 + 2026-11-13 https://menew.web.app/vs/otter - 2026-11-12 + 2026-11-13 diff --git a/docs/vs/cleanshot/index.html b/docs/vs/cleanshot/index.html index c63364b..f5b2cd7 100644 --- a/docs/vs/cleanshot/index.html +++ b/docs/vs/cleanshot/index.html @@ -15,7 +15,7 @@ - +