Scaffold YubiKey-gated Proton Bridge rewrite
ober
55a265173a4ec7203f2696971cbf7d69777bc994
new file mode 100644 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +*.so +*.dylib +*.o +*.wpo +*.boot +*.log +tmp/ +cache/ +vault.local new file mode 100644 --- /dev/null +++ b/LICENSE @@ -0,0 +1,5 @@ +This project is intended to be GPL-3.0-or-later compatible because it is a +Jerboa rewrite informed by Proton Mail Bridge, which is GPL-3.0-or-later. + +Add the full GPL-3.0-or-later license text before distributing binaries or +accepting external contributions. new file mode 100644 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +JERBOA_HOME ?= $(realpath $(CURDIR)/../jerboa) +SCHEME ?= $(JERBOA_HOME)/.chez/bin/scheme +JERBOA_YUBIKEY_DIR ?= $(realpath $(CURDIR)/../jerboa-yubikey) +JERBOA_MAIL_DIR ?= $(realpath $(CURDIR)/../jerboa-mail) +LIBDIRS := $(CURDIR):$(JERBOA_YUBIKEY_DIR):$(JERBOA_MAIL_DIR):$(JERBOA_HOME)/lib + +.PHONY: help run test clean +.DEFAULT_GOAL := help + +help: + @echo "jerboa-proton-bridge" + @echo "" + @echo "Development:" + @echo " make run ARGS='--help' Run the CLI" + @echo " make test Run smoke tests" + @echo " make clean Remove local generated files" + @echo "" + @echo "Environment:" + @echo " JERBOA_HOME = $(JERBOA_HOME)" + @echo " SCHEME = $(SCHEME)" + @echo " JERBOA_YUBIKEY_DIR = $(JERBOA_YUBIKEY_DIR)" + @echo " JERBOA_MAIL_DIR = $(JERBOA_MAIL_DIR)" + +run: + JERBOA_HOME=$(JERBOA_HOME) \ + $(SCHEME) -q --libdirs $(LIBDIRS) --script main.ss -- $(ARGS) + +test: + JERBOA_HOME=$(JERBOA_HOME) \ + $(SCHEME) -q --libdirs $(LIBDIRS) --script test/test-all.ss + +clean: + rm -rf tmp cache new file mode 100644 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# jerboa-proton-bridge + +YubiKey-gated Proton Mail client and Proton Bridge rewrite in Jerboa. + +This project is a rewrite/reimplementation effort using Proton Mail Bridge as +a GPL-3.0-or-later reference, but it is not intended to reproduce the same +security boundary. The specific goal is to avoid a long-lived local IMAP +password that allows mail access without a fresh YubiKey-gated authorization. + +Upstream reference clone: + +```text +/Users/user/mine/proton-bridge-upstream +``` + +Current companion libraries: + +- `/Users/user/mine/jerboa-yubikey` +- `/Users/user/mine/jerboa-mail` +- `/Users/user/mine/jerboa-pgp` +- `/Users/user/mine/jerboa-https` +- `/Users/user/mine/jerboa-crypto` + +## Security Model + +The upstream Bridge authenticates to Proton, stores session/key material, then +exposes local IMAP/SMTP with generated credentials. That is useful for normal +mail clients, but it means local possession of Bridge IMAP credentials is +enough to read decrypted mail while Bridge is running. + +This rewrite starts with stricter rules: + +- No password-authenticated local IMAP server in the default mode. +- No reusable Bridge password equivalent in the default mode. +- Every interactive mail-reading session must pass a YubiKey gate. +- The YubiKey gate should use Proton's actual FIDO2/WebAuthn challenge when + authenticating to Proton. +- Any local cache must be encrypted and separately YubiKey-gated. +- SMTP/send remains out of scope until read-only access is correct. + +## Reference Findings + +The upstream Bridge clone is Go and uses: + +- `github.com/ProtonMail/go-proton-api` +- `github.com/ProtonMail/go-srp` +- `github.com/ProtonMail/gopenpgp/v2` +- `github.com/ProtonMail/gluon` +- `github.com/ProtonMail/go-libfido2` + +Relevant upstream paths: + +- `internal/bridge/user.go` - login, 2FA, key unlock, refresh. +- `internal/fido/` - FIDO2/WebAuthn assertion flow. +- `internal/vault/` - stored auth/key material. +- `internal/services/imapservice/` - IMAP-facing service. +- `pkg/message/` and `pkg/mime/` - message decrypt/build helpers. + +## First Target + +Build a read-only native Proton CLI: + +```sh +jerboa-proton-bridge login +jerboa-proton-bridge folders +jerboa-proton-bridge list --folder INBOX +jerboa-proton-bridge show --folder INBOX --id MESSAGE_ID +``` + +Only after native auth/decrypt works should we decide whether to expose any +local protocol. If we do, it should be a YubiKey-gated IPC protocol or +single-user Unix socket, not a reusable IMAP password. + +## Development + +```sh +make run ARGS='--help' +make test +``` new file mode 100644 --- /dev/null +++ b/main.ss @@ -0,0 +1,29 @@ +#!chezscheme +;;; Script entry point for jerboa-proton-bridge. + +(import (except (chezscheme) + make-hash-table hash-table? + sort sort! + printf fprintf + path-extension path-absolute? + with-input-from-string with-output-to-string + iota 1+ 1- + partition + make-date make-time)) + +(define home (or (getenv "HOME") ".")) +(define jerboa-dir + (or (getenv "JERBOA_HOME") + (string-append home "/mine/jerboa"))) +(define project-dir (current-directory)) + +(library-directories + (append + (list (cons project-dir project-dir) + (cons (string-append jerboa-dir "/lib") + (string-append jerboa-dir "/lib"))) + (library-directories))) + +(import (proton-bridge cli)) + +(run-cli (command-line-arguments)) new file mode 100644 --- /dev/null +++ b/proton-bridge/cli.ss @@ -0,0 +1,87 @@ +#!chezscheme +;;; (proton-bridge cli) - command shell for the Jerboa rewrite. + +(library (proton-bridge cli) + (export run-cli usage-string) + + (import (except (chezscheme) + make-hash-table hash-table? + sort sort! + printf fprintf + path-extension path-absolute? + with-input-from-string with-output-to-string + iota 1+ 1- + partition + make-date make-time) + (proton-bridge security)) + + (define version "0.0.0-rewrite-plan") + + (define usage-string + (string-append + "usage: jerboa-proton-bridge <command> [options]\n" + "\n" + "commands:\n" + " status Show rewrite/security status\n" + " login Native Proton login with YubiKey gate (planned)\n" + " folders List Proton folders (planned)\n" + " list List messages (planned)\n" + " show Fetch/decrypt/show one message (planned)\n" + " help Print this help\n" + " version Print version\n")) + + (define (println s) + (display s) + (newline)) + + (define (die code msg) + (display msg (current-error-port)) + (newline (current-error-port)) + (exit code)) + + (define (strip-script-separator args) + (if (and (pair? args) (string=? (car args) "--")) + (cdr args) + args)) + + (define (print-status) + (println "jerboa-proton-bridge") + (println (string-append "version: " version)) + (println "upstream reference: /Users/user/mine/proton-bridge-upstream") + (println "security:") + (for-each + (lambda (item) + (println + (string-append + " " + (symbol->string (car item)) + ": " + (if (cdr item) "yes" "no")))) + (security-summary)) + (println "native Proton auth: planned") + (println "FIDO2/WebAuthn via jerboa-yubikey: planned") + (println "password-authenticated local IMAP: intentionally disabled")) + + (define (planned command) + (die 2 + (string-append + command + " is planned; native Proton auth and FIDO2 are not implemented yet"))) + + (define (run-cli raw-args) + (let ([args (strip-script-separator raw-args)]) + (cond + [(null? args) (display usage-string)] + [(or (string=? (car args) "-h") + (string=? (car args) "--help") + (string=? (car args) "help")) + (display usage-string)] + [(string=? (car args) "version") (println version)] + [(string=? (car args) "status") (print-status)] + [(string=? (car args) "login") (planned "login")] + [(string=? (car args) "folders") (planned "folders")] + [(string=? (car args) "list") (planned "list")] + [(string=? (car args) "show") (planned "show")] + [else (die 2 (string-append "unknown command: " (car args)))]))) + + ) ;; end library new file mode 100644 --- /dev/null +++ b/proton-bridge/security.ss @@ -0,0 +1,45 @@ +#!chezscheme +;;; (proton-bridge security) - explicit security policy for this rewrite. + +(library (proton-bridge security) + (export + default-security-policy + policy-allows-password-imap? + policy-requires-yubikey? + security-summary) + + (import (except (chezscheme) + make-hash-table hash-table? + sort sort! + printf fprintf + path-extension path-absolute? + with-input-from-string with-output-to-string + iota 1+ 1- + partition + make-date make-time)) + + (define (default-security-policy) + '((password-imap . #f) + (yubikey-required . #t) + (smtp-enabled . #f) + (plaintext-cache . #f))) + + (define (policy-ref policy key default) + (let ([item (assq key policy)]) + (if item (cdr item) default))) + + (define (policy-allows-password-imap? policy) + (policy-ref policy 'password-imap #f)) + + (define (policy-requires-yubikey? policy) + (policy-ref policy 'yubikey-required #t)) + + (define (security-summary) + (let ([policy (default-security-policy)]) + (list + (cons 'password-imap (policy-allows-password-imap? policy)) + (cons 'yubikey-required (policy-requires-yubikey? policy)) + (cons 'smtp-enabled #f) + (cons 'plaintext-cache #f)))) + + ) ;; end library new file mode 100644 --- /dev/null +++ b/test/test-all.ss @@ -0,0 +1,74 @@ +#!chezscheme +;;; Smoke tests for jerboa-proton-bridge. + +(import (except (chezscheme) + make-hash-table hash-table? + sort sort! + printf fprintf + path-extension path-absolute? + with-input-from-string with-output-to-string + iota 1+ 1- + partition + make-date make-time)) + +(define home (or (getenv "HOME") ".")) +(define jerboa-dir + (or (getenv "JERBOA_HOME") + (string-append home "/mine/jerboa"))) +(define project-dir (current-directory)) + +(library-directories + (append + (list (cons project-dir project-dir) + (cons (string-append jerboa-dir "/lib") + (string-append jerboa-dir "/lib"))) + (library-directories))) + +(import (proton-bridge cli)) +(import (proton-bridge security)) + +(define failures 0) + +(define (pass! name) + (fprintf (current-error-port) " PASS ~a~%" name)) + +(define (fail! name detail) + (set! failures (+ failures 1)) + (fprintf (current-error-port) " FAIL ~a (~a)~%" name detail)) + +(define-syntax check + (syntax-rules () + [(_ name expr) + (let ([res (guard (e [#t e]) + expr)]) + (cond + [(condition? res) + (fail! name (condition-message res))] + [res (pass! name)] + [else (fail! name "returned #f")]))])) + +(fprintf (current-error-port) "jerboa-proton-bridge smoke tests~%") +(fprintf (current-error-port) "=================================~%") + +(check "usage mentions status" + (let ([needle "status"] + [haystack usage-string]) + (let loop ([i 0]) + (cond + [(> (+ i (string-length needle)) (string-length haystack)) #f] + [(string=? needle (substring haystack i (+ i (string-length needle)))) #t] + [else (loop (+ i 1))])))) + +(check "default policy disables password IMAP" + (not (policy-allows-password-imap? (default-security-policy)))) + +(check "default policy requires YubiKey" + (policy-requires-yubikey? (default-security-policy))) + +(if (= failures 0) + (begin + (fprintf (current-error-port) "~%All tests passed.~%") + (exit 0)) + (begin + (fprintf (current-error-port) "~%~a test(s) failed.~%" failures) + (exit 1)))