Files
git-cli/credentials.rkt
T
2026-08-10 15:46:39 +02:00

231 lines
8.8 KiB
Racket

#lang racket/base
(require crypto
crypto/all
net/base64
racket/file
racket/string
simple-ini)
(provide git-credentials-store
git-credentials-unlock-store
current-git-credentials-store
current-git-credentials-unlock-store
git-credentials-init!
git-credentials-unlock!
git-credentials-lock!
git-credentials-unlocked?
git-credentials-unlock-expires
git-credentials-set!
git-credentials-ref
git-credentials-configured?
git-credentials-remove!)
(define git-credentials-store 'racket-git)
(define git-credentials-unlock-store 'racket-git-unlock)
;; Parameters make the storage location overridable for tests or embedded use,
;; while the public default remains the normal Racket preference stores.
(define current-git-credentials-store (make-parameter git-credentials-store))
(define current-git-credentials-unlock-store (make-parameter git-credentials-unlock-store))
(define settings-section 'settings)
(define unlock-section 'unlock)
(define kdf-iterations 200000)
(define cipher '(aes gcm))
(define check-text #"racket-git credential store")
(define-syntax-rule (with-git-crypto body ...)
(parameterize ([crypto-factories all-factories])
body ...))
(define (b64-encode bytes)
(bytes->string/utf-8 (base64-encode bytes #"")))
(define (b64-decode string)
(base64-decode (string->bytes/utf-8 string)))
(define (store-read name)
(file->ini name))
(define (store-write name ini)
(make-directory* (find-system-path 'pref-dir))
(ini->file ini name #:private? #t)
(void))
(define (store-set! name section key value)
(define ini (store-read name))
(ini-set! ini section key value)
(store-write name ini))
(define (store-get name section key [default #f])
(ini-get (store-read name) section key default))
(define (derive-key password salt)
(with-git-crypto
(pbkdf2-hmac 'sha256
(string->bytes/utf-8 password)
salt
#:iterations kdf-iterations
#:key-size 32)))
(define (encrypt-value key plaintext aad)
(with-git-crypto
(define iv (generate-cipher-iv cipher))
(define encrypted
(encrypt cipher key iv (string->bytes/utf-8 plaintext)
#:aad (string->bytes/utf-8 aad)))
(string-append (b64-encode iv) ":" (b64-encode encrypted))))
(define (decrypt-value key encoded aad)
(with-git-crypto
(define parts (string-split encoded ":"))
(unless (= (length parts) 2)
(error 'git-credentials "invalid encrypted credential data"))
(bytes->string/utf-8
(decrypt cipher key
(b64-decode (car parts))
(b64-decode (cadr parts))
#:aad (string->bytes/utf-8 aad)))))
(define (credential-key remote)
(define url-match
(regexp-match #px"^[A-Za-z][A-Za-z0-9+.-]*://(?:[^/@]+@)?([^/:]+)" remote))
(define ssh-match
(regexp-match #px"^[^@]+@([^:]+):" remote))
(string-downcase
(cond
[url-match (cadr url-match)]
[ssh-match (cadr ssh-match)]
[else remote])))
(define (string->hex string)
(apply string-append
(for/list ([b (in-bytes (string->bytes/utf-8 string))])
(let ([h (number->string b 16)])
(if (= (string-length h) 1) (string-append "0" h) h)))))
(define (credential-section remote)
;; simple-ini deliberately accepts a conservative section-name syntax.
;; Hex keeps arbitrary host names reversible and section-safe.
(string->symbol (string-append "credential."
(string->hex (credential-key remote)))))
(define (git-credentials-init! password #:unlock-for [seconds 86400])
(unless (and (string? password) (positive? (string-length password)))
(raise-argument-error 'git-credentials-init! "non-empty string?" password))
(define existing-salt (store-get (current-git-credentials-store) settings-section 'salt #f))
(when existing-salt
(error 'git-credentials-init! "credential store is already initialized"))
(define salt (with-git-crypto (crypto-random-bytes 16)))
(define key (derive-key password salt))
(define ini (store-read (current-git-credentials-store)))
(ini-set! ini settings-section 'version 1)
(ini-set! ini settings-section 'kdf "pbkdf2-hmac-sha256")
(ini-set! ini settings-section 'iterations kdf-iterations)
(ini-set! ini settings-section 'salt (b64-encode salt))
(ini-set! ini settings-section 'check
(encrypt-value key (bytes->string/utf-8 check-text) "check"))
(store-write (current-git-credentials-store) ini)
(cache-unlock-key! key seconds)
(void))
(define (cache-unlock-key! key seconds)
(unless (and (real? seconds) (> seconds 0))
(raise-argument-error 'git-credentials-unlock! "positive real?" seconds))
(define ini (store-read (current-git-credentials-unlock-store)))
(ini-set! ini unlock-section 'key (b64-encode key))
(ini-set! ini unlock-section 'expires (+ (current-seconds) seconds))
(store-write (current-git-credentials-unlock-store) ini)
(void))
(define (git-credentials-unlock! password #:for [seconds 86400])
(define salt-text (store-get (current-git-credentials-store) settings-section 'salt #f))
(define check (store-get (current-git-credentials-store) settings-section 'check #f))
(unless (and salt-text check)
(error 'git-credentials-unlock! "credential store is not initialized"))
(define key (derive-key password (b64-decode salt-text)))
(with-handlers ([exn:fail?
(lambda (_)
(error 'git-credentials-unlock! "invalid password"))])
(unless (string=? (decrypt-value key check "check")
(bytes->string/utf-8 check-text))
(error 'git-credentials-unlock! "invalid password")))
(cache-unlock-key! key seconds)
(void))
(define (git-credentials-lock!)
(define ini (store-read (current-git-credentials-unlock-store)))
(ini-set! ini unlock-section 'key "")
(ini-set! ini unlock-section 'expires 0)
(store-write (current-git-credentials-unlock-store) ini)
(void))
(define (git-credentials-unlock-expires)
(define expires (store-get (current-git-credentials-unlock-store) unlock-section 'expires 0))
(if (number? expires) expires 0))
(define (git-credentials-unlocked?)
(define key (store-get (current-git-credentials-unlock-store) unlock-section 'key ""))
(define expires (git-credentials-unlock-expires))
(cond
[(and (string? key)
(not (string=? key ""))
(> expires (current-seconds)))
#t]
[else
(when (and (number? expires) (positive? expires))
(git-credentials-lock!))
#f]))
(define (current-key who)
(unless (git-credentials-unlocked?)
(error who "credential store 'racket-git is locked"))
(b64-decode
(store-get (current-git-credentials-unlock-store) unlock-section 'key "")))
(define (git-credentials-set! remote username token)
(unless (string? remote)
(raise-argument-error 'git-credentials-set! "string?" remote))
(unless (string? username)
(raise-argument-error 'git-credentials-set! "string?" username))
(unless (string? token)
(raise-argument-error 'git-credentials-set! "string?" token))
(define key (current-key 'git-credentials-set!))
(define section (credential-section remote))
(define ini (store-read (current-git-credentials-store)))
(ini-set! ini section 'username username)
(ini-set! ini section 'token
(encrypt-value key token (string-append (credential-key remote) ":" username)))
(store-write (current-git-credentials-store) ini)
(void))
(define (git-credentials-configured? remote)
(define section (credential-section remote))
(define username (store-get (current-git-credentials-store) section 'username #f))
(define encrypted (store-get (current-git-credentials-store) section 'token #f))
(and (string? username) (not (string=? username ""))
(string? encrypted) (not (string=? encrypted ""))))
(define (git-credentials-ref remote)
(define section (credential-section remote))
(define username (store-get (current-git-credentials-store) section 'username #f))
(define encrypted (store-get (current-git-credentials-store) section 'token #f))
(cond
[(and (string? username) (not (string=? username ""))
(string? encrypted) (not (string=? encrypted "")))
(define key (current-key 'git-credentials-ref))
(cons username
(decrypt-value key encrypted (string-append (credential-key remote) ":" username)))]
[else #f]))
(define (git-credentials-remove! remote)
;; simple-ini has no section-delete primitive. Clearing both values keeps
;; the file format simple and makes git-credentials-ref return #f.
(define section (credential-section remote))
(define ini (store-read (current-git-credentials-store)))
(ini-set! ini section 'username "")
(ini-set! ini section 'token "")
(store-write (current-git-credentials-store) ini)
(void))