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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# CHANGELOG


**v0.1.7 - 09-24-2026**

- icfx container signing/verification refactor and hardening
- Hardware keys: all hidapi calls run on one OS-locked worker thread


**v0.1.6 - 09-11-2026**

- Pin goroutine to OS thread for macos
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ A Go library providing post-quantum ready encryption, signing, key management, a
- Hybrid post-quantum encryption ([Age](https://github.com/FiloSottile/age) X25519 + ML-KEM-768)
- Digital signatures (ML-DSA-65 / FIPS 204)
- Git commit signing (post-quantum ML-DSA-65 signatures via `GitSign`) — a drop-in for git's `gpg.program`
- `.icfx` binary container format (metadata + payload + signature) across four format profiles
- Constant-memory streaming encryption for large files (digest-signed)
- `.icfx` binary container format: sealed metadata + payload + a signature that binds the plaintext, the ciphertext and the profile
- Constant-memory streaming encryption and verification for large files
- Container decrypt with signature verification (against current keys, rotated-out keys, and self)
- Encrypted file sharing (upload/download of pre-encrypted `.icfx` ciphertext)
- Contact groups / multi-recipient encryption
Expand All @@ -40,7 +40,7 @@ A Go library providing post-quantum ready encryption, signing, key management, a
|---------|-------------|
| `crypto` | Hybrid encryption (Age + ML-KEM-768), ML-DSA-65 signing, git commit signing, key generation, 128-bit fingerprinting |
| `format` | ICFX binary container format, Age format support, PEM armor, format detection |
| `encrypt` | Produces `.icfx` containers across all four format profiles, including constant-memory digest-signed streaming |
| `encrypt` | Produces `.icfx` containers (private by default, or with an advisory public header) in constant memory, signing plaintext + ciphertext + profile |
| `decrypt` | Container-aware decrypt and signature verification (resolves the signer against contacts, rotated-out keys, and self) |
| `recipient` | Resolves a recipient reference (alias, email, nickname, contact, own identity, or raw `age1pq1` lock) to key material, with one shared resolution order across clients |
| `identity` | Encrypted identity store, primary identity enforcement, and `Unlocked` operations API (memguard-backed Sign/Decrypt/Encrypt/GitSign/Export) |
Expand Down
89 changes: 0 additions & 89 deletions crypto/icfx_v2_signed_test.go

This file was deleted.

48 changes: 48 additions & 0 deletions crypto/sigmsg.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package crypto

import (
"bytes"
"crypto/sha512"
"encoding/binary"
"hash"
)

// containerCanonicalTag domain-separates container signatures from every other
// message an identity's signing key produces (lock self-signatures, rotations,
// revocations — see qr.lockCanonicalTag and the bundle tags). Each message
// begins with its own length-framed tag, so no two protocols can ever sign the
// same bytes.
const containerCanonicalTag = "ICFX-CONTAINER-V1"

// NewContainerDigest returns the hash used for both digests in a container's
// signed message (SHA-512). The encrypt (sign) and decrypt (verify) sides MUST
// use this same constructor so their digests agree.
func NewContainerDigest() hash.Hash { return sha512.New() }

// SignedMessage builds the fixed-size message a container signature covers:
//
// tag || profile || SHA-512(plaintext) || SHA-512(ciphertext)
//
// with every field 8-byte big-endian length-framed. Binding the plaintext
// digest proves the signer knew the content, not merely a ciphertext somebody
// else produced. Binding the ciphertext digest ties the signature to this exact
// encryption — recipients included — so a recipient cannot re-encrypt the
// plaintext to a third party under the original signature. Binding the profile
// byte stops a container from being re-labelled as another layout.
func SignedMessage(profile byte, plaintextDigest, ciphertextDigest []byte) []byte {
var b bytes.Buffer
writeField(&b, []byte(containerCanonicalTag))
writeField(&b, []byte{profile})
writeField(&b, plaintextDigest)
writeField(&b, ciphertextDigest)
return b.Bytes()
}

// writeField appends an 8-byte big-endian length followed by the bytes — the
// same framing every other canonical signed message in icfx uses.
func writeField(buf *bytes.Buffer, b []byte) {
var n [8]byte
binary.BigEndian.PutUint64(n[:], uint64(len(b)))
buf.Write(n[:])
buf.Write(b)
}
64 changes: 64 additions & 0 deletions crypto/sigmsg_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package crypto

import (
"bytes"
"encoding/binary"
"testing"
)

func digestOf(b byte) []byte { return bytes.Repeat([]byte{b}, NewContainerDigest().Size()) }

// TestSignedMessageFraming pins the exact bytes a container signature covers:
// a length-framed tag first, then the profile, then the two digests.
func TestSignedMessageFraming(t *testing.T) {
msg := SignedMessage(0x05, digestOf(0xAA), digestOf(0xBB))

tagLen := binary.BigEndian.Uint64(msg[:8])
if int(tagLen) != len(containerCanonicalTag) || string(msg[8:8+tagLen]) != containerCanonicalTag {
t.Fatalf("message must begin with the length-framed tag, got %q", msg[:8+tagLen])
}
want := 8 + len(containerCanonicalTag) + 8 + 1 + 8 + 64 + 8 + 64
if len(msg) != want {
t.Fatalf("message length = %d, want %d", len(msg), want)
}
if !bytes.Equal(msg, SignedMessage(0x05, digestOf(0xAA), digestOf(0xBB))) {
t.Fatal("SignedMessage must be deterministic")
}
}

// TestSignedMessageBindsEveryField: changing the profile or either digest
// changes the message — none of them can be swapped without the signature
// noticing.
func TestSignedMessageBindsEveryField(t *testing.T) {
base := SignedMessage(0x05, digestOf(0xAA), digestOf(0xBB))
variants := map[string][]byte{
"profile": SignedMessage(0x06, digestOf(0xAA), digestOf(0xBB)),
"plaintext": SignedMessage(0x05, digestOf(0xAC), digestOf(0xBB)),
"ciphertext": SignedMessage(0x05, digestOf(0xAA), digestOf(0xBD)),
}
for name, v := range variants {
if bytes.Equal(base, v) {
t.Fatalf("changing the %s must change the signed message", name)
}
}
}

// TestSignedMessageRoundTrip: a real ML-DSA-65 signature over the message
// verifies, and fails once any bound field changes.
func TestSignedMessageRoundTrip(t *testing.T) {
kp, err := GenerateKeyPair()
if err != nil {
t.Fatal(err)
}
msg := SignedMessage(0x05, digestOf(0x01), digestOf(0x02))
sig, err := Sign(msg, kp.SigningPrivateKey)
if err != nil {
t.Fatal(err)
}
if ok, err := Verify(msg, sig, kp.SigningPublicKey); err != nil || !ok {
t.Fatalf("signature must verify: ok=%v err=%v", ok, err)
}
if ok, _ := Verify(SignedMessage(0x06, digestOf(0x01), digestOf(0x02)), sig, kp.SigningPublicKey); ok {
t.Fatal("signature must not verify under another profile byte")
}
}
29 changes: 0 additions & 29 deletions crypto/v3sig.go

This file was deleted.

4 changes: 4 additions & 0 deletions crypto/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import (
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
)

// SigningPublicKeySize is the byte length of an ML-DSA-65 public key — the
// only shape a raw lock passed to Verify can have.
const SigningPublicKeySize = mldsa65.PublicKeySize

// Verify checks an ML-DSA-65 (FIPS 204) signature against the given data and public key.
func Verify(data []byte, signature []byte, publicKey []byte) (bool, error) {
pubKey := new(mldsa65.PublicKey)
Expand Down
Loading
Loading