Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# CHANGELOG


**v0.1.4 - 09-09-2026**

- Fixed Windows identity store


**v0.1.3 - 09-07-2026**

- Bumped go-libfido2
Expand Down
97 changes: 95 additions & 2 deletions keystore/keychain_keyring.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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)
}

Expand All @@ -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)
Expand Down Expand Up @@ -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
}
127 changes: 127 additions & 0 deletions keystore/keychain_keyring_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading