From 0e6e1b7d31301f3deb9f753f3d2b4ddb9604c361 Mon Sep 17 00:00:00 2001 From: hkdb Date: Tue, 8 Sep 2026 20:48:28 -0700 Subject: [PATCH] v0.1.4 --- CHANGELOG.md | 5 ++ keystore/keychain_keyring.go | 97 ++++++++++++++++++++++- keystore/keychain_keyring_test.go | 127 ++++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 keystore/keychain_keyring_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 76ae728..b51c75b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # CHANGELOG +**v0.1.4 - 09-09-2026** + +- Fixed Windows identity store + + **v0.1.3 - 09-07-2026** - Bumped go-libfido2 diff --git a/keystore/keychain_keyring.go b/keystore/keychain_keyring.go index c54f648..768af4b 100644 --- a/keystore/keychain_keyring.go +++ b/keystore/keychain_keyring.go @@ -7,10 +7,24 @@ import ( "encoding/json" "errors" "fmt" + "runtime" + "strconv" + "strings" "github.com/zalando/go-keyring" ) +// maxKeychainChunk bounds the size of a single keychain value. Windows Credential +// Manager rejects a blob over 2560 bytes (CRED_MAX_CREDENTIAL_BLOB_SIZE), but a +// base64 ML-DSA-65 signing key is ~5376 bytes — so on Windows it must be split. +// base64 is ASCII (1 byte/char), so 2048 stays well under the cap → 3 chunks. +const maxKeychainChunk = 2048 + +// chunkMarkerPrefix tags the main "-sign" entry as a chunk manifest ("icfx-chunks:N") +// instead of the key itself. A base64 string never contains '-' or ':', so a marker +// is unambiguously distinguishable from a whole (unchunked) key. +const chunkMarkerPrefix = "icfx-chunks:" + // KeychainStore stores keys in the OS keychain via go-keyring, which is pure Go // (no cgo) and self-dispatches per platform: D-Bus Secret Service on Linux and // the BSDs (OpenBSD/NetBSD unconditionally; FreeBSD/DragonFly when cgo is on), @@ -52,17 +66,55 @@ func (k *KeychainStore) LoadEncryptionIdentity(name string) (string, error) { func (k *KeychainStore) StoreSigningKey(name string, key []byte) error { encoded := base64.StdEncoding.EncodeToString(key) + // Only Windows' Credential Manager has the small per-blob cap; every other + // non-darwin backend (Secret Service) stores the key whole, exactly as before. + if runtime.GOOS == "windows" && len(encoded) > maxKeychainChunk { + return k.storeSigningKeyChunked(name, encoded) + } if err := keyring.Set(keychainService, name+signKeySuffix, encoded); err != nil { return fmt.Errorf("storing signing key: %w", err) } return k.addToIndex(name) } +// storeSigningKeyChunked splits an oversized base64 key across N "-sign.i" entries +// and writes a "chunked:N" marker to the main "-sign" entry. Chunks are written +// BEFORE the marker so an interrupted store can't leave a marker pointing at +// missing chunks. (The ML-DSA key size is fixed, so a re-store yields the same N +// and never orphans a chunk.) +func (k *KeychainStore) storeSigningKeyChunked(name, encoded string) error { + base := name + signKeySuffix + chunks := chunkString(encoded, maxKeychainChunk) + for i, c := range chunks { + if err := keyring.Set(keychainService, fmt.Sprintf("%s.%d", base, i), c); err != nil { + return fmt.Errorf("storing signing key chunk %d: %w", i, err) + } + } + if err := keyring.Set(keychainService, base, chunkMarker(len(chunks))); err != nil { + return fmt.Errorf("storing signing key: %w", err) + } + return k.addToIndex(name) +} + func (k *KeychainStore) LoadSigningKey(name string) ([]byte, error) { - encoded, err := keyring.Get(keychainService, name+signKeySuffix) + base := name + signKeySuffix + encoded, err := keyring.Get(keychainService, base) if err != nil { return nil, fmt.Errorf("loading signing key: %w", err) } + // A chunked key (Windows) stores a "chunked:N" marker here; reassemble the N + // pieces. Otherwise `encoded` is the whole key (Linux/BSD, or pre-chunking). + if n, ok := parseChunkMarker(encoded); ok { + var b strings.Builder + for i := 0; i < n; i++ { + c, cerr := keyring.Get(keychainService, fmt.Sprintf("%s.%d", base, i)) + if cerr != nil { + return nil, fmt.Errorf("loading signing key chunk %d: %w", i, cerr) + } + b.WriteString(c) + } + encoded = b.String() + } return base64.StdEncoding.DecodeString(encoded) } @@ -74,10 +126,20 @@ func (k *KeychainStore) HasKeys(name string) bool { func (k *KeychainStore) Clear(name string) error { // Delete is idempotent: missing entries are not an error condition. + base := name + signKeySuffix + // If the signing key was chunked (Windows), delete its chunk entries too + // (best-effort — a missing chunk is not an error). + if val, err := keyring.Get(keychainService, base); err == nil { + if n, ok := parseChunkMarker(val); ok { + for i := 0; i < n; i++ { + _ = keyring.Delete(keychainService, fmt.Sprintf("%s.%d", base, i)) + } + } + } if err := keyring.Delete(keychainService, name+encKeySuffix); err != nil && !errors.Is(err, keyring.ErrNotFound) { return fmt.Errorf("deleting encryption identity: %w", err) } - if err := keyring.Delete(keychainService, name+signKeySuffix); err != nil && !errors.Is(err, keyring.ErrNotFound) { + if err := keyring.Delete(keychainService, base); err != nil && !errors.Is(err, keyring.ErrNotFound) { return fmt.Errorf("deleting signing key: %w", err) } return k.removeFromIndex(name) @@ -146,3 +208,34 @@ func (k *KeychainStore) removeFromIndex(name string) error { } return nil } + +// chunkString splits s into consecutive pieces of at most size bytes. Rejoining +// the returned pieces in order reproduces s exactly; a size <= 0 returns s whole. +func chunkString(s string, size int) []string { + if size <= 0 { + return []string{s} + } + var chunks []string + for len(s) > size { + chunks = append(chunks, s[:size]) + s = s[size:] + } + return append(chunks, s) +} + +// chunkMarker renders the manifest value stored in the main "-sign" entry. +func chunkMarker(n int) string { + return chunkMarkerPrefix + strconv.Itoa(n) +} + +// parseChunkMarker reports whether v is a chunk manifest and, if so, its count. +func parseChunkMarker(v string) (int, bool) { + if !strings.HasPrefix(v, chunkMarkerPrefix) { + return 0, false + } + n, err := strconv.Atoi(v[len(chunkMarkerPrefix):]) + if err != nil || n < 0 { + return 0, false + } + return n, true +} diff --git a/keystore/keychain_keyring_test.go b/keystore/keychain_keyring_test.go new file mode 100644 index 0000000..db62385 --- /dev/null +++ b/keystore/keychain_keyring_test.go @@ -0,0 +1,127 @@ +//go:build !darwin + +package keystore + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "fmt" + "strings" + "testing" + + "github.com/zalando/go-keyring" +) + +func TestChunkString(t *testing.T) { + cases := []struct { + name string + in string + size int + }{ + {"empty", "", 4}, + {"smaller than size", "abc", 4}, + {"equal to size", "abcd", 4}, + {"one over", "abcde", 4}, + {"multi chunk", strings.Repeat("x", 4100), 2048}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + chunks := chunkString(c.in, c.size) + for i, ch := range chunks { + if len(ch) > c.size { + t.Fatalf("chunk %d len %d exceeds size %d", i, len(ch), c.size) + } + } + if got := strings.Join(chunks, ""); got != c.in { + t.Fatalf("rejoined = %q, want %q", got, c.in) + } + }) + } +} + +func TestParseChunkMarker(t *testing.T) { + if n, ok := parseChunkMarker(chunkMarker(3)); !ok || n != 3 { + t.Fatalf("parseChunkMarker(chunkMarker(3)) = (%d, %v), want (3, true)", n, ok) + } + // A base64 string is never mistaken for a marker (no '-' or ':' in base64). + b64 := base64.StdEncoding.EncodeToString([]byte("some key bytes here to encode")) + if n, ok := parseChunkMarker(b64); ok { + t.Fatalf("parseChunkMarker(%q) = (%d, true), want ok=false", b64, n) + } + if _, ok := parseChunkMarker("icfx-chunks:notanumber"); ok { + t.Fatal("non-numeric marker parsed as valid") + } +} + +// A >2560-byte signing key round-trips through the chunked path, and Clear removes +// the marker AND every chunk. Uses go-keyring's in-memory mock so it runs headless +// on any platform (the runtime.GOOS gate lives in StoreSigningKey; the test calls +// storeSigningKeyChunked directly to exercise the chunked path regardless of GOOS). +func TestChunkedSigningKeyRoundTrip(t *testing.T) { + keyring.MockInit() + k := NewKeychainStore() + + key := make([]byte, 4032) // ML-DSA-65 secret key size → base64 ~5376 > maxKeychainChunk + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + encoded := base64.StdEncoding.EncodeToString(key) + + if err := k.StoreEncryptionIdentity("id", "age-identity-placeholder"); err != nil { + t.Fatalf("StoreEncryptionIdentity: %v", err) + } + if err := k.storeSigningKeyChunked("id", encoded); err != nil { + t.Fatalf("storeSigningKeyChunked: %v", err) + } + + // The main entry is a marker, not the whole key. + main, err := keyring.Get(keychainService, "id"+signKeySuffix) + if err != nil { + t.Fatalf("get main entry: %v", err) + } + n, ok := parseChunkMarker(main) + if !ok || n < 2 { + t.Fatalf("main entry not a multi-chunk marker: %q", main) + } + + if got, lerr := k.LoadSigningKey("id"); lerr != nil { + t.Fatalf("LoadSigningKey: %v", lerr) + } else if !bytes.Equal(got, key) { + t.Fatalf("round-trip mismatch: got %d bytes, want %d", len(got), len(key)) + } + + if !k.HasKeys("id") { + t.Fatal("HasKeys = false after storing enc + chunked signing key") + } + + if err := k.Clear("id"); err != nil { + t.Fatalf("Clear: %v", err) + } + if _, err := keyring.Get(keychainService, "id"+signKeySuffix); err == nil { + t.Fatal("signing key entry still present after Clear") + } + for i := 0; i < n; i++ { + if _, err := keyring.Get(keychainService, fmt.Sprintf("%s.%d", "id"+signKeySuffix, i)); err == nil { + t.Fatalf("chunk %d still present after Clear", i) + } + } +} + +// An existing whole-key entry (pre-chunking / Linux) still loads. +func TestWholeSigningKeyBackwardCompat(t *testing.T) { + keyring.MockInit() + k := NewKeychainStore() + + key := []byte("a small signing key") + if err := keyring.Set(keychainService, "id"+signKeySuffix, base64.StdEncoding.EncodeToString(key)); err != nil { + t.Fatal(err) + } + got, err := k.LoadSigningKey("id") + if err != nil { + t.Fatalf("LoadSigningKey (whole): %v", err) + } + if !bytes.Equal(got, key) { + t.Fatalf("whole-key load mismatch: got %q, want %q", got, key) + } +}