From cac97aae6184f2f2af5ceeabeae7e2ced3481064 Mon Sep 17 00:00:00 2001 From: Rose Hogenson Date: Fri, 10 Oct 2025 22:37:41 -0700 Subject: Initial commit --- internal/sym/dec.go | 106 +++++++++++++++++++++++++ internal/sym/enc.go | 102 ++++++++++++++++++++++++ internal/sym/oae.go | 203 +++++++++++++++++++++++++++++++++++++++++++++++ internal/sym/pwhash.go | 12 +++ internal/sym/sym_test.go | 69 ++++++++++++++++ 5 files changed, 492 insertions(+) create mode 100644 internal/sym/dec.go create mode 100644 internal/sym/enc.go create mode 100644 internal/sym/oae.go create mode 100644 internal/sym/pwhash.go create mode 100644 internal/sym/sym_test.go (limited to 'internal') diff --git a/internal/sym/dec.go b/internal/sym/dec.go new file mode 100644 index 0000000..7cbf5d4 --- /dev/null +++ b/internal/sym/dec.go @@ -0,0 +1,106 @@ +package sym + +import ( + "bufio" + "bytes" + "encoding/base64" + "errors" + "fmt" + "io" + "os" + "strings" +) + +type lineReader struct { + r bufio.Scanner + line []byte +} + +func (r *lineReader) Read(buf []byte) (int, error) { + for len(r.line) == 0 { + if !r.r.Scan() { + if err := r.r.Err(); err != nil { + return 0, err + } + return 0, io.EOF + } + line := r.r.Bytes() + if bytes.HasPrefix(line, []byte("-")) { + continue + } + r.line = line + } + n := copy(buf, r.line) + r.line = r.line[n:] + return n, nil +} + +type PasswordCache map[[SaltSize]byte][]byte + +func decryptBinary(w io.Writer, r io.Reader, password string, pwCache PasswordCache) error { + header := make([]byte, 1+SaltSize) + if _, err := io.ReadFull(r, header); err != nil { + return err + } + salt := header[1:] + key, ok := pwCache[[SaltSize]byte(salt)] + if !ok { + var err error + key, err = HashPassword(password, salt) + if err != nil { + return err + } + pwCache[[SaltSize]byte(salt)] = key + } + reader := newDecryptingReader(r, key) + _, err := io.Copy(w, reader) + return err +} + +func Decrypt(w io.Writer, r io.Reader, password string, pwCache PasswordCache) error { + bufReader := bufio.NewReaderSize(r, 1) + b, err := bufReader.Peek(1) + if err != nil { + if err == io.EOF { + return fmt.Errorf("no input") + } + return err + } + if b[0] == 0 { + return decryptBinary(w, bufReader, password, pwCache) + } + if b[0] != '-' { + return errors.New("invalid input") + } + return decryptBinary(w, base64.NewDecoder(base64.StdEncoding, &lineReader{r: *bufio.NewScanner(bufReader)}), password, pwCache) +} + +func DecryptFile(fileName, password string, pwCache PasswordCache) (err error) { + var outFileName string + if name, ok := strings.CutSuffix(fileName, ".enc"); ok { + outFileName = name + } else if name, ok := strings.CutSuffix(fileName, ".enc.txt"); ok { + outFileName = name + } else { + outFileName = fileName + ".dec" + } + fIn, err := os.Open(fileName) + if err != nil { + return err + } + defer fIn.Close() + fOut, err := os.Create(outFileName) + if err != nil { + return err + } + defer func() { + fOut.Close() + if err != nil { + os.Remove(fOut.Name()) + } + }() + if err := Decrypt(fOut, fIn, password, pwCache); err != nil { + return err + } + return fOut.Close() +} diff --git a/internal/sym/enc.go b/internal/sym/enc.go new file mode 100644 index 0000000..32ec307 --- /dev/null +++ b/internal/sym/enc.go @@ -0,0 +1,102 @@ +package sym + +import ( + "bufio" + "encoding/base64" + "encoding/binary" + "io" + "os" +) + +type newlineWriter struct { + w *bufio.Writer + n int +} + +func (w *newlineWriter) Write(buf []byte) (int, error) { + const lineSize = 80 + nn := 0 + for len(buf) > 0 { + if w.n == lineSize { + if err := w.w.WriteByte('\n'); err != nil { + return nn, err + } + w.n = 0 + } + n, err := w.w.Write(buf[:min(lineSize-w.n, len(buf))]) + nn += n + buf = buf[n:] + w.n += n + if err != nil { + return nn, err + } + } + return nn, nil +} + +func Encrypt(w io.Writer, r io.Reader, key, salt []byte, counter int) error { + if _, err := w.Write([]byte{0}); err != nil { + return err + } + if _, err := w.Write(salt); err != nil { + return err + } + var noncePrefix [noncePrefixSize]byte + binary.BigEndian.PutUint32(noncePrefix[:], uint32(counter)) + writer := newEncryptingWriter(w, key, noncePrefix) + if _, err := io.Copy(writer, r); err != nil { + return err + } + return writer.Close() +} + +func EncryptBase64(w io.Writer, r io.Reader, key, salt []byte, count int) error { + bufWriter := bufio.NewWriter(w) + if _, err := bufWriter.WriteString(`-------------------------- Begin encrypted text block -------------------------- +-------------------------- am i cool like gpg? --------------------------------- +`); err != nil { + return err + } + base64Writer := base64.NewEncoder(base64.StdEncoding, &newlineWriter{w: bufWriter}) + if err := Encrypt(base64Writer, r, key, salt, count); err != nil { + return err + } + if err := base64Writer.Close(); err != nil { + return err + } + if err := bufWriter.WriteByte('\n'); err != nil { + return err + } + return bufWriter.Flush() +} + +func EncryptFile(fileName string, key, salt []byte, count int, asciiOutput bool) (err error) { + f, err := os.Open(fileName) + if err != nil { + return err + } + defer f.Close() + ext := ".enc" + if asciiOutput { + ext = ".enc.txt" + } + fOut, err := os.Create(fileName + ext) + if err != nil { + return err + } + defer func() { + fOut.Close() + if err != nil { + os.Remove(fOut.Name()) + } + }() + if asciiOutput { + err = EncryptBase64(fOut, f, key, salt, count) + } else { + err = Encrypt(fOut, f, key, salt, count) + } + if err != nil { + return err + } + return fOut.Close() +} diff --git a/internal/sym/oae.go b/internal/sym/oae.go new file mode 100644 index 0000000..e71d5c3 --- /dev/null +++ b/internal/sym/oae.go @@ -0,0 +1,203 @@ +package sym + +import ( + "bufio" + "bytes" + "crypto/aes" + "crypto/cipher" + "encoding/binary" + "errors" + "io" + "math" +) + +const ( + nonceSize = 12 + aeadOverhead = 16 + noncePrefixSize = nonceSize - 8 + + segmentSize = 4 * 1024 * 1024 + encryptedSegmentSize = segmentSize + aeadOverhead +) + +type segmentEncrypter struct { + key []byte + noncePrefix [noncePrefixSize]byte + + aead cipher.AEAD + i uint64 +} + +func (se *segmentEncrypter) initialize() error { + block, err := aes.NewCipher(se.key) + if err != nil { + return err + } + se.aead, err = cipher.NewGCM(block) + return err +} + +func (se *segmentEncrypter) nonce(lastSegment bool) ([]byte, []byte, error) { + if se.i == math.MaxUint64 { + return nil, nil, errors.New("counter overflowed") + } + nonce := make([]byte, nonceSize) + copy(nonce, se.noncePrefix[:]) + binary.BigEndian.PutUint64(nonce[noncePrefixSize:], se.i) + ad := make([]byte, 9) + binary.BigEndian.PutUint64(ad, se.i) + if lastSegment { + ad[8] = 1 + } + se.i++ + return nonce, ad, nil +} + +func (se *segmentEncrypter) encrypt(out, buf []byte, lastSegment bool) ([]byte, error) { + nonce, ad, err := se.nonce(lastSegment) + if err != nil { + return nil, err + } + return se.aead.Seal(out, nonce, buf, ad), nil +} + +func (se *segmentEncrypter) decrypt(out, buf []byte, lastSegment bool) ([]byte, error) { + nonce, ad, err := se.nonce(lastSegment) + if err != nil { + return nil, err + } + return se.aead.Open(out, nonce, buf, ad) +} + +type encryptingWriter struct { + w io.Writer + encrypter segmentEncrypter + buf []byte + initialized bool +} + +func newEncryptingWriter(w io.Writer, key []byte, noncePrefix [noncePrefixSize]byte) *encryptingWriter { + return &encryptingWriter{ + w: w, + encrypter: segmentEncrypter{ + key: key, + noncePrefix: noncePrefix, + }, + } +} + +func (w *encryptingWriter) initialize() error { + if w.initialized { + return nil + } + if err := w.encrypter.initialize(); err != nil { + return err + } + if _, err := w.w.Write(w.encrypter.noncePrefix[:]); err != nil { + return err + } + w.buf = make([]byte, 0, encryptedSegmentSize) + w.initialized = true + return nil +} + +func (w *encryptingWriter) writeBuf(lastSegment bool) error { + var err error + if w.buf, err = w.encrypter.encrypt(w.buf[:0], w.buf, lastSegment); err != nil { + return err + } + if _, err := w.w.Write(w.buf); err != nil { + return err + } + w.buf = w.buf[:0] + return nil +} + +func (w *encryptingWriter) Write(buf []byte) (int, error) { + if err := w.initialize(); err != nil { + return 0, err + } + nn := 0 + for len(buf) > 0 { + if len(w.buf) == segmentSize { + if err := w.writeBuf(false); err != nil { + return nn, err + } + } + n := copy(w.buf[len(w.buf):segmentSize], buf) + nn += n + w.buf = w.buf[:len(w.buf)+n] + buf = buf[n:] + } + return nn, nil +} + +func (w *encryptingWriter) Close() error { + if err := w.initialize(); err != nil { + return err + } + return w.writeBuf(true) +} + +type decryptingReader struct { + r *bufio.Reader + decrypter segmentEncrypter + buf bytes.Buffer + initialized bool +} + +func newDecryptingReader(r io.Reader, key []byte) *decryptingReader { + return &decryptingReader{ + r: bufio.NewReaderSize(r, 1), + decrypter: segmentEncrypter{ + key: key, + }, + } +} + +func (r *decryptingReader) initialize() error { + if r.initialized { + return nil + } + if _, err := io.ReadFull(r.r, r.decrypter.noncePrefix[:]); err != nil { + return err + } + if err := r.decrypter.initialize(); err != nil { + return err + } + r.buf = *bytes.NewBuffer(make([]byte, 0, encryptedSegmentSize)) + r.initialized = true + return nil +} + +func (r *decryptingReader) fillBuf() error { + r.buf.Reset() + buf := r.buf.AvailableBuffer()[:encryptedSegmentSize] + n, err := io.ReadFull(r.r, buf) + if n == 0 { + if err == io.ErrUnexpectedEOF { + return io.EOF + } + return err + } + buf = buf[:n] + _, readErr := r.r.Peek(1) + buf, err = r.decrypter.decrypt(buf[:0], buf, readErr == io.EOF) + if err != nil { + return err + } + r.buf.Write(buf) + return nil +} + +func (r *decryptingReader) Read(buf []byte) (int, error) { + if err := r.initialize(); err != nil { + return 0, err + } + if r.buf.Len() == 0 { + if err := r.fillBuf(); err != nil { + return 0, err + } + } + return r.buf.Read(buf) +} diff --git a/internal/sym/pwhash.go b/internal/sym/pwhash.go new file mode 100644 index 0000000..ef17182 --- /dev/null +++ b/internal/sym/pwhash.go @@ -0,0 +1,12 @@ +package sym + +import ( + "crypto/pbkdf2" + "crypto/sha256" +) + +const SaltSize = 16 + +func HashPassword(password string, salt []byte) ([]byte, error) { + return pbkdf2.Key(sha256.New, password, salt, 35_000_000, 32) +} diff --git a/internal/sym/sym_test.go b/internal/sym/sym_test.go new file mode 100644 index 0000000..0ab0144 --- /dev/null +++ b/internal/sym/sym_test.go @@ -0,0 +1,69 @@ +package sym + +import ( + "bytes" + "encoding/hex" + "os" + "path/filepath" + "testing" +) + +func mustHex(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + if err != nil { + t.Fatalf("Bad hex %q: %s", s, err) + } + return b +} + +func TestEncryptDecrypt(t *testing.T) { + t.Parallel() + + const password = "karp cache tidal mars fed rajah uses graze pobox flew" + salt := mustHex(t, "9aa7d8bb6d19f794162f4062c789b230") + key, err := HashPassword(password, salt) + if err != nil { + t.Fatalf("HashPassword failed: %s", err) + } + buf := make([]byte, 10*1024*1024) + for i := range buf { + buf[i] = byte(i) + } + for _, tc := range []struct { + desc string + ascii bool + }{{ + desc: "binary", + ascii: false, + }, { + desc: "ascii", + ascii: true, + }} { + t.Run(tc.desc, func(t *testing.T) { + t.Parallel() + + fileName := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(fileName, buf, 0600); err != nil { + t.Fatalf("Failed to write test file: %s", err) + } + if err := EncryptFile(fileName, key, salt, 0, tc.ascii); err != nil { + t.Fatalf("EncryptFile failed: %s", err) + } + ext := ".enc" + if tc.ascii { + ext = ".enc.txt" + } + if err := DecryptFile(fileName+ext, password, make(PasswordCache)); err != nil { + t.Fatalf("DecryptFile failed: %s", err) + } + gotContents, err := os.ReadFile(fileName) + if err != nil { + t.Fatalf("Failed to read file: %s", err) + } + if !bytes.Equal(gotContents, buf) { + t.Errorf("contents differ") + } + }) + } +} -- cgit v1.3.1