diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fedb8a..e7ffe8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index cd0c599..4ddc7e5 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) | diff --git a/crypto/icfx_v2_signed_test.go b/crypto/icfx_v2_signed_test.go deleted file mode 100644 index ec3e890..0000000 --- a/crypto/icfx_v2_signed_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package crypto - -import ( - "bytes" - "testing" - "time" - - "github.com/instacryptio/icfx/format" -) - -// TestSignedPrivateContainerRoundTrip pins the full v2 signed chain the -// clients implement: encode inner metadata → encrypt → sign the payload → -// serialize private → (share-path strip is a no-op on private) → -// deserialize → decrypt → decode inner metadata → verify the signature -// with the fingerprint revealed by decryption. -func TestSignedPrivateContainerRoundTrip(t *testing.T) { - kp, err := GenerateKeyPair() - if err != nil { - t.Fatalf("keypair: %v", err) - } - - filedata := []byte("signed and private file contents") - meta := format.Metadata{ - SenderFingerprint: kp.Fingerprint, - Timestamp: time.Now().UTC(), - OriginalFilename: "quarterly.xlsx", - IsSigned: true, - } - inner, err := format.EncodePayload(meta, filedata) - if err != nil { - t.Fatalf("encode payload: %v", err) - } - payload, err := Encrypt(inner, []string{kp.EncryptionRecipient}) - if err != nil { - t.Fatalf("encrypt: %v", err) - } - sig, err := Sign(payload, kp.SigningPrivateKey) - if err != nil { - t.Fatalf("sign: %v", err) - } - - container := &format.Container{ - Profile: format.ProfilePrivateBuffered, - Payload: payload, - Signature: sig, - Private: true, - } - data, err := container.Serialize() - if err != nil { - t.Fatalf("serialize: %v", err) - } - if bytes.Contains(data, []byte("quarterly")) || bytes.Contains(data, []byte(kp.Fingerprint)) { - t.Fatal("private container leaked metadata") - } - - // Recipient path: parse, decrypt, peel inner metadata, verify. - parsed, err := format.Deserialize(data) - if err != nil { - t.Fatalf("deserialize: %v", err) - } - if !parsed.Private { - t.Fatal("expected private container") - } - plain, err := Decrypt(parsed.Payload, kp.EncryptionIdentity) - if err != nil { - t.Fatalf("decrypt: %v", err) - } - gotMeta, gotFile, err := format.DecodePayload(plain) - if err != nil { - t.Fatalf("decode payload: %v", err) - } - if gotMeta.SenderFingerprint != kp.Fingerprint || !gotMeta.IsSigned || gotMeta.OriginalFilename != "quarterly.xlsx" { - t.Fatalf("inner metadata: %+v", gotMeta) - } - if !bytes.Equal(gotFile, filedata) { - t.Fatal("file bytes mismatch") - } - ok, err := Verify(parsed.Payload, parsed.Signature, kp.SigningPublicKey) - if err != nil || !ok { - t.Fatalf("signature must verify after decrypt: ok=%v err=%v", ok, err) - } - - // Tampered payload fails verification. - bad := append([]byte{}, parsed.Payload...) - bad[len(bad)/2] ^= 0xFF - if ok, _ := Verify(bad, parsed.Signature, kp.SigningPublicKey); ok { - t.Fatal("tampered payload must not verify") - } -} diff --git a/crypto/sigmsg.go b/crypto/sigmsg.go new file mode 100644 index 0000000..b7e91de --- /dev/null +++ b/crypto/sigmsg.go @@ -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) +} diff --git a/crypto/sigmsg_test.go b/crypto/sigmsg_test.go new file mode 100644 index 0000000..447cfc6 --- /dev/null +++ b/crypto/sigmsg_test.go @@ -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") + } +} diff --git a/crypto/v3sig.go b/crypto/v3sig.go deleted file mode 100644 index 0bccefd..0000000 --- a/crypto/v3sig.go +++ /dev/null @@ -1,29 +0,0 @@ -package crypto - -import ( - "crypto/sha512" - "hash" -) - -// V3SigDomain domain-separates the ICFX v3 container's digest-based signature -// (the message signed is this tag followed by the payload digest). It binds the -// signature to the v3 context so a v3 signature can never be reinterpreted as a -// v2 signature (which is taken over the raw ciphertext) or vice versa — belt and -// suspenders on top of the version-byte dispatch. -const V3SigDomain = "ICFXv3\x00" - -// NewV3Digest returns the hash used for the v3 payload digest (SHA-512, 256-bit -// collision resistance). The encrypt (sign) and decrypt (verify) sides MUST use -// this same constructor so their digests agree. -func NewV3Digest() hash.Hash { return sha512.New() } - -// V3SignedMessage builds the small, fixed-size message that a v3 container -// signs/verifies: the domain tag followed by the payload digest. Signing this -// (~72 bytes) instead of the whole ciphertext lets the digest be computed -// incrementally, so sign/verify use constant memory. -func V3SignedMessage(payloadDigest []byte) []byte { - msg := make([]byte, 0, len(V3SigDomain)+len(payloadDigest)) - msg = append(msg, V3SigDomain...) - msg = append(msg, payloadDigest...) - return msg -} diff --git a/crypto/verify.go b/crypto/verify.go index c6efa58..beea76c 100644 --- a/crypto/verify.go +++ b/crypto/verify.go @@ -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) diff --git a/decrypt/attack_test.go b/decrypt/attack_test.go new file mode 100644 index 0000000..dcb2338 --- /dev/null +++ b/decrypt/attack_test.go @@ -0,0 +1,201 @@ +package decrypt + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/instacryptio/icfx/contacts" + "github.com/instacryptio/icfx/crypto" + "github.com/instacryptio/icfx/format" +) + +// These tests are the threat model of the container signature: every way an +// attacker might re-attribute, strip, forge or re-label a container must end +// in VerifyFailed — never in VerifyOK naming anyone but the real signer. + +// mustFail decrypts as the recipient with the given contacts and asserts the +// verdict is VerifyFailed (plaintext may or may not be intact — the point is +// the attribution). +func mustFail(t *testing.T, data []byte, recipient *party, cl []contacts.Contact, what string) VerifyResult { + t.Helper() + var out bytes.Buffer + res, err := DecryptAndVerifyStream(bytes.NewReader(data), &out, recipient.u, cl) + if err != nil { + t.Fatalf("%s: decrypt must still succeed, got %v", what, err) + } + if res.Status != VerifyFailed { + t.Fatalf("%s: want VerifyFailed, got %+v", what, res) + } + return res +} + +// TestFingerprintSwapFails is the reported attack: an attacker takes someone +// else's public container, rewrites the advisory header to name themselves and +// re-signs under their own key. Verification must key off the sealed +// metadata and fail — never report the attacker as the author. +func TestFingerprintSwapFails(t *testing.T) { + author, recipient, attacker := newParty(t, "author"), newParty(t, "recipient"), newParty(t, "attacker") + file := []byte("the author wrote this") + p := split(t, encryptContainer(t, author, recipient, file, format.ProfilePublic, true)) + + // Header now claims the attacker; the attacker signs the best message they + // can build (they cannot decrypt, so the plaintext digest is a guess). + var hm format.Metadata + if err := json.Unmarshal(p.headerMeta, &hm); err != nil { + t.Fatal(err) + } + hm.SenderFingerprint = attacker.kp.Fingerprint + p.headerMeta, _ = json.Marshal(hm) + sig, err := attacker.u.Sign(crypto.SignedMessage(byte(p.profile), digest(nil), digest(p.payload))) + if err != nil { + t.Fatal(err) + } + p.signature = sig + + cl := []contacts.Contact{contactFor("author", author), contactFor("attacker", attacker)} + res := mustFail(t, p.join(), recipient, cl, "fingerprint swap") + if res.SignerAlias == "attacker" || res.SignerFP == attacker.kp.Fingerprint { + t.Fatalf("attacker must never be named as signer: %+v", res) + } +} + +// TestRecipientCannotReattribute: a legitimate recipient knows the plaintext, +// so they can build the exact signed message — but the sealed metadata still +// names the author, so their signature resolves against the author's key and +// fails. Claiming authorship requires re-encrypting, i.e. a new container of +// their own. +func TestRecipientCannotReattribute(t *testing.T) { + author, recipient := newParty(t, "author"), newParty(t, "recipient") + file := []byte("shared secret") + p := split(t, encryptContainer(t, author, recipient, file, format.ProfilePrivate, true)) + + sig, err := recipient.u.Sign(crypto.SignedMessage(byte(p.profile), digest(file), digest(p.payload))) + if err != nil { + t.Fatal(err) + } + p.signature = sig + + res := mustFail(t, p.join(), recipient, []contacts.Contact{contactFor("author", author)}, "recipient re-sign") + if res.SignerFP != author.kp.Fingerprint { + t.Fatalf("the sealed author must still be the named signer: %+v", res) + } +} + +// TestSignatureStripFails: removing the signature from a file whose sealed +// metadata says it is signed is tampering, not an unsigned file. +func TestSignatureStripFails(t *testing.T) { + author, recipient := newParty(t, "author"), newParty(t, "recipient") + p := split(t, encryptContainer(t, author, recipient, []byte("signed"), format.ProfilePrivate, true)) + p.signature = nil + + res := mustFail(t, p.join(), recipient, []contacts.Contact{contactFor("author", author)}, "signature strip") + if res.SignerFP != author.kp.Fingerprint { + t.Fatalf("stripped container must still name its signer: %+v", res) + } +} + +// TestBoltOnSignatureFails: nobody may add a signature to a file its author +// left unsigned. +func TestBoltOnSignatureFails(t *testing.T) { + author, recipient, attacker := newParty(t, "author"), newParty(t, "recipient"), newParty(t, "attacker") + file := []byte("unsigned by design") + p := split(t, encryptContainer(t, author, recipient, file, format.ProfilePrivate, false)) + + sig, err := attacker.u.Sign(crypto.SignedMessage(byte(p.profile), digest(file), digest(p.payload))) + if err != nil { + t.Fatal(err) + } + p.signature = sig + + mustFail(t, p.join(), recipient, []contacts.Contact{contactFor("attacker", attacker)}, "bolt-on signature") +} + +// TestHeaderMismatchFails: an advisory header that disagrees with the sealed +// metadata in any field is a failure, even with the signature intact. +func TestHeaderMismatchFails(t *testing.T) { + author, recipient := newParty(t, "author"), newParty(t, "recipient") + p := split(t, encryptContainer(t, author, recipient, []byte("x"), format.ProfilePublic, true)) + + var hm format.Metadata + if err := json.Unmarshal(p.headerMeta, &hm); err != nil { + t.Fatal(err) + } + hm.OriginalFilename = "invoice.pdf" + p.headerMeta, _ = json.Marshal(hm) + + mustFail(t, p.join(), recipient, []contacts.Contact{contactFor("author", author)}, "header mismatch") +} + +// TestStrippedHeaderStillVerifies is the sharing path: the upload strips the +// advisory header (format.ZeroMetaLen) and the container must still verify +// with full authorship from the sealed copy. +func TestStrippedHeaderStillVerifies(t *testing.T) { + author, recipient := newParty(t, "author"), newParty(t, "recipient") + file := []byte("shared through the cloud") + p := split(t, encryptContainer(t, author, recipient, file, format.ProfilePublic, true)) + p.headerMeta = nil + + plain, res := decryptContainerBytes(t, p.join(), recipient.u, []contacts.Contact{contactFor("author", author)}) + if !bytes.Equal(plain, file) || res.Status != VerifyOK || res.SignerAlias != "author" { + t.Fatalf("stripped public container must verify from sealed metadata: %+v", res) + } +} + +// TestProfileFlipFails: the profile byte is inside the signed message, so +// re-labelling a container as another layout fails verification. +func TestProfileFlipFails(t *testing.T) { + author, recipient := newParty(t, "author"), newParty(t, "recipient") + cl := []contacts.Contact{contactFor("author", author)} + + // 0x05 → 0x06 (a header-less public container is a valid layout). + p := split(t, encryptContainer(t, author, recipient, []byte("x"), format.ProfilePrivate, true)) + p.profile = format.ProfilePublic + mustFail(t, p.join(), recipient, cl, "private→public flip") + + // 0x05 → legacy 0x03: decrypts through the legacy path, never verifies. + p.profile = format.LegacyProfilePrivateStreaming + mustFail(t, p.join(), recipient, cl, "downgrade to legacy") + + // 0x06 → 0x05 while keeping the header is not a valid layout at all. + q := split(t, encryptContainer(t, author, recipient, []byte("x"), format.ProfilePublic, true)) + q.profile = format.ProfilePrivate + if _, err := DecryptAndVerifyStream(bytes.NewReader(q.join()), &bytes.Buffer{}, recipient.u, cl); err == nil { + t.Fatal("a private profile with a header must be rejected") + } +} + +// TestSpliceSignatureFails: a valid signature from one container cannot be +// moved onto another with different content. +func TestSpliceSignatureFails(t *testing.T) { + author, recipient := newParty(t, "author"), newParty(t, "recipient") + a := split(t, encryptContainer(t, author, recipient, []byte("first"), format.ProfilePrivate, true)) + b := split(t, encryptContainer(t, author, recipient, []byte("second"), format.ProfilePrivate, true)) + b.signature = a.signature + + mustFail(t, b.join(), recipient, []contacts.Contact{contactFor("author", author)}, "spliced signature") +} + +func TestTamperedSignatureFails(t *testing.T) { + author, recipient := newParty(t, "author"), newParty(t, "recipient") + data := encryptContainer(t, author, recipient, []byte("payload"), format.ProfilePrivate, true) + tampered := append([]byte{}, data...) + tampered[len(tampered)-1] ^= 0xFF // last byte is inside the signature + + mustFail(t, tampered, recipient, []contacts.Contact{contactFor("author", author)}, "tampered signature") +} + +func TestTamperedCiphertextNeverVerifies(t *testing.T) { + author, recipient := newParty(t, "author"), newParty(t, "recipient") + data := encryptContainer(t, author, recipient, []byte("tamper me"), format.ProfilePrivate, true) + tampered := append([]byte{}, data...) + tampered[len(tampered)/2] ^= 0xFF // somewhere in the payload + + var out bytes.Buffer + res, err := DecryptAndVerifyStream(bytes.NewReader(tampered), &out, recipient.u, []contacts.Contact{contactFor("author", author)}) + // age rejects the tampered ciphertext (decrypt error) OR the signature + // fails — either way it must NOT report VerifyOK. + if err == nil && res.Status == VerifyOK { + t.Fatalf("tampered ciphertext must not verify OK, got %+v", res) + } +} diff --git a/decrypt/decrypt.go b/decrypt/decrypt.go index 7e68dc0..5d8e625 100644 --- a/decrypt/decrypt.go +++ b/decrypt/decrypt.go @@ -1,25 +1,33 @@ // Package decrypt is icfx's container-aware decrypt + signature-verification // path: it parses an .icfx container, decrypts it with the caller's unlocked -// identity, and — when the container is signed — verifies the signature against -// the caller's contacts (current keys, then rotated-out/previous keys) and -// finally the unlocked identity itself. It is the single implementation shared -// by every client (local decrypt, file-share receive, and signature checks), so -// verification policy can never drift between them. +// identity, and — when the sealed metadata says the container is signed — +// verifies the signature against the caller's contacts (current keys, then +// rotated-out/previous keys) and finally the unlocked identity itself. It is +// the single implementation shared by every client (local decrypt, file-share +// receive, and signature checks), so verification policy can never drift +// between them. // -// Signature verification is ADVISORY to decryption: an unverifiable signature -// never blocks decryption (the plaintext is still returned). Callers inspect the -// returned VerifyResult and decide how strict to be — a verify command exits -// non-zero, while decrypt/receive surface a warning. +// The signature covers the plaintext, the ciphertext and the profile byte +// (crypto.SignedMessage), and the signer is resolved only from the metadata +// sealed inside the ciphertext — never from a plaintext header. Verification +// is therefore always post-decrypt, and for the streaming path the verdict is +// known only after the last plaintext byte has been written to dst. +// +// The library never withholds plaintext: the bytes are always written and the +// VerifyResult reports what was found. Callers gate on it — a VerifyFailed +// means the file claims a signer it cannot prove, so clients decrypt to a +// temporary location, ask the user, and only then promote or discard. package decrypt import ( + "bytes" "encoding/base64" "fmt" + "strings" "time" "github.com/instacryptio/icfx/contacts" "github.com/instacryptio/icfx/crypto" - "github.com/instacryptio/icfx/format" "github.com/instacryptio/icfx/identity" ) @@ -27,26 +35,46 @@ import ( type VerifyStatus int const ( - // VerifyUnsigned: the container carried no signature. + // VerifyUnsigned: the container carries no signature and claims none. VerifyUnsigned VerifyStatus = iota - // VerifyOK: the signature matched a known signer (see VerifyResult for who). + // VerifyOK: the signature verified against a known signer (see + // VerifyResult for who) and binds the plaintext that was written. VerifyOK - // VerifyUnverifiable: the container is signed, but no available key matched - // (unknown sender, or the signature did not verify). - VerifyUnverifiable - // VerifyNoMetadata: the container is signed but carries no recoverable - // sender metadata to verify against — a header-stripped pre-v2 (v1) file. - VerifyNoMetadata + // VerifyFailed: the container claims a signature that does not hold — + // a signer key was resolved but the signature or the plaintext does not + // match it, the signature was stripped or bolted on, an advisory header + // disagrees with the sealed metadata, or the container is a legacy layout + // whose signature scheme is no longer honoured. Treat as tampered. + VerifyFailed + // VerifyUnknownSigner: the container is signed by a fingerprint that + // matches no contact and not the unlocked identity, so the signature could + // not be checked. The sender may simply not be imported yet. + VerifyUnknownSigner ) +// String names the status for logs and fallthrough display arms. +func (s VerifyStatus) String() string { + switch s { + case VerifyUnsigned: + return "unsigned" + case VerifyOK: + return "ok" + case VerifyFailed: + return "failed" + case VerifyUnknownSigner: + return "unknown-signer" + } + return fmt.Sprintf("VerifyStatus(%d)", int(s)) +} + // VerifyResult is the structured verification outcome. Clients render it however // they like (styled terminal lines, a bridge JSON string, an exit code) — the // library composes no display text. type VerifyResult struct { Status VerifyStatus - // SignerFP is the sender fingerprint taken from the AUTHENTICATED metadata - // (v2 inner metadata, or the v1 header). Set whenever the container names a - // signer, so callers can show it even on VerifyUnverifiable. + // SignerFP is the sender fingerprint from the SEALED metadata. Set whenever + // the container names a signer, so callers can show who it claims to be + // even on VerifyFailed / VerifyUnknownSigner. SignerFP string // SignerAlias is the matched contact's alias (set on VerifyOK via a contact). SignerAlias string @@ -64,112 +92,71 @@ type Result struct { Verify VerifyResult } -// DecryptAndVerify parses an .icfx container, decrypts its payload with u, and -// verifies any signature. See the package doc for the advisory-verification -// contract. contactList may be nil/empty (verification then falls back to the -// unlocked identity only). -// -// Verification always runs POST-decrypt against the AUTHENTICATED metadata: for -// v2 that is the inner metadata sealed inside the payload (the plaintext header -// is disposable and must never be trusted for signer selection); for v1 the -// header is the only copy. The signer key is resolved in order: the sender's -// current contact key, then that contact's previous/revoked keys, then the -// unlocked identity itself (encrypt-to-self). +// DecryptAndVerify is the in-memory form of DecryptAndVerifyStream: the whole +// container is in data and the whole plaintext comes back in Result. See the +// package doc for the verification contract. func DecryptAndVerify(data []byte, u *identity.Unlocked, contactList []contacts.Contact) (Result, error) { - container, err := format.Deserialize(data) + var out bytes.Buffer + verify, err := DecryptAndVerifyStream(bytes.NewReader(data), &out, u, contactList) if err != nil { - return Result{}, fmt.Errorf("parsing ICFX container: %w", err) + return Result{}, err } + return Result{Plaintext: out.Bytes(), Verify: verify}, nil +} - plaintext, err := u.Decrypt(container.Payload) - if err != nil { - return Result{}, fmt.Errorf("decrypting payload: %w", err) +// verifySignature resolves the signer's public key for senderFP and checks +// signature over message. Search order: every contact whose current key +// carries the fingerprint (the store does not enforce uniqueness), then +// contacts' previous/revoked keys, then the unlocked identity itself. +// Fingerprints compare case-insensitively, as contacts.FindByFingerprint does. +// A fingerprint that resolves to a usable key whose signature does not verify +// is VerifyFailed; one that resolves to nothing usable is VerifyUnknownSigner +// (an undecodable stored key says nothing about the file). +func verifySignature(message, signature []byte, senderFP string, u *identity.Unlocked, contactList []contacts.Contact) VerifyResult { + if senderFP == "" { + return VerifyResult{Status: VerifyUnknownSigner} } - - // Recover the authenticated metadata + the original file bytes. A private - // profile seals a metadata copy inside the payload; a public profile's - // payload is the bare file bytes, carrying metadata (if any) only in the - // plaintext header. - meta := container.Metadata - filedata := plaintext - if !container.Profile.Public() { - meta, filedata, err = format.DecodePayload(plaintext) + resolved := false + verifies := func(encodedKey string) bool { + pub, err := base64.StdEncoding.DecodeString(encodedKey) if err != nil { - return Result{}, fmt.Errorf("parsing inner metadata: %w", err) - } - } - - // A header-stripped public container has no metadata anywhere: if it was - // signed the signature can't be checked; return the bytes with NoMetadata. - if container.Private && container.Profile.Public() { - status := VerifyUnsigned - if len(container.Signature) > 0 { - status = VerifyNoMetadata + return false } - return Result{Plaintext: filedata, Verify: VerifyResult{Status: status}}, nil + resolved = true + ok, verr := crypto.Verify(message, signature, pub) + return verr == nil && ok } - if !meta.IsSigned { - return Result{Plaintext: filedata, Verify: VerifyResult{Status: VerifyUnsigned}}, nil - } - // The AUTHENTICATED inner metadata says this container is signed, but the - // container carries no signature bytes — an active attacker stripped the - // outer signature. Report it as unverifiable (with the named signer), not as - // a clean "unsigned" file. - if len(container.Signature) == 0 { - return Result{Plaintext: filedata, Verify: VerifyResult{Status: VerifyUnverifiable, SignerFP: meta.SenderFingerprint}}, nil - } - - res := verifySignature(container.Payload, container.Signature, meta.SenderFingerprint, u, contactList) - return Result{Plaintext: filedata, Verify: res}, nil -} - -// verifySignature resolves the signer's public key and checks the signature -// (always taken over the encrypted payload). Search order: contact current key, -// contact previous/revoked keys, then the unlocked identity itself. -func verifySignature(payload, signature []byte, senderFP string, u *identity.Unlocked, contactList []contacts.Contact) VerifyResult { - // (a) current contact key by fingerprint. + // (a) current contact keys by fingerprint. for _, c := range contactList { - if c.Fingerprint != senderFP { - continue + if strings.EqualFold(c.Fingerprint, senderFP) && verifies(c.SignPubKey) { + return VerifyResult{Status: VerifyOK, SignerFP: senderFP, SignerAlias: c.Alias} } - if pub, err := base64.StdEncoding.DecodeString(c.SignPubKey); err == nil { - if ok, verr := crypto.Verify(payload, signature, pub); verr == nil && ok { - return VerifyResult{Status: VerifyOK, SignerFP: senderFP, SignerAlias: c.Alias} - } - } - break // fingerprint is unique; current key didn't verify — fall through to previous keys } // (b) previous/revoked contact keys — a signature made before the contact // rotated stays valid, but is flagged. for _, c := range contactList { for _, prev := range c.PreviousKeys { - if prev.Fingerprint != senderFP { - continue - } - if pub, err := base64.StdEncoding.DecodeString(prev.SignPubKey); err == nil { - if ok, verr := crypto.Verify(payload, signature, pub); verr == nil && ok { - return VerifyResult{ - Status: VerifyOK, - SignerFP: senderFP, - SignerAlias: c.Alias, - UsedRevokedKey: true, - RevokedAt: prev.RevokedAt, - } + if strings.EqualFold(prev.Fingerprint, senderFP) && verifies(prev.SignPubKey) { + return VerifyResult{ + Status: VerifyOK, + SignerFP: senderFP, + SignerAlias: c.Alias, + UsedRevokedKey: true, + RevokedAt: prev.RevokedAt, } } } } // (c) self — the file was signed by the identity we're decrypting with. - if u.Fingerprint() == senderFP { - if pub, err := base64.StdEncoding.DecodeString(u.Info().SignPubKey); err == nil { - if ok, verr := crypto.Verify(payload, signature, pub); verr == nil && ok { - return VerifyResult{Status: VerifyOK, SignerFP: senderFP, SignerIdentity: u.Name()} - } - } + if strings.EqualFold(u.Fingerprint(), senderFP) && verifies(u.Info().SignPubKey) { + return VerifyResult{Status: VerifyOK, SignerFP: senderFP, SignerIdentity: u.Name()} } - return VerifyResult{Status: VerifyUnverifiable, SignerFP: senderFP} + if resolved { + return VerifyResult{Status: VerifyFailed, SignerFP: senderFP} + } + return VerifyResult{Status: VerifyUnknownSigner, SignerFP: senderFP} } diff --git a/decrypt/decrypt_test.go b/decrypt/decrypt_test.go index e9cdc41..bfc5bb2 100644 --- a/decrypt/decrypt_test.go +++ b/decrypt/decrypt_test.go @@ -3,12 +3,15 @@ package decrypt import ( "bytes" "encoding/base64" + "encoding/binary" "errors" + "strings" "testing" "time" "github.com/instacryptio/icfx/contacts" "github.com/instacryptio/icfx/crypto" + "github.com/instacryptio/icfx/encrypt" "github.com/instacryptio/icfx/format" "github.com/instacryptio/icfx/identity" ) @@ -75,6 +78,22 @@ func newParty(t *testing.T, name string) *party { if err != nil { t.Fatalf("generating keypair: %v", err) } + return unlockParty(t, name, kp, kp.Fingerprint) +} + +// newPartyWithFingerprint is newParty with the identity's recorded fingerprint +// replaced — for exercising a store whose metadata is wrong or missing. +func newPartyWithFingerprint(t *testing.T, name, fingerprint string) *party { + t.Helper() + kp, err := crypto.GenerateKeyPair() + if err != nil { + t.Fatalf("generating keypair: %v", err) + } + return unlockParty(t, name, kp, fingerprint) +} + +func unlockParty(t *testing.T, name string, kp *crypto.KeyPair, fingerprint string) *party { + t.Helper() ks := newMemKeystore() if err := ks.StoreEncryptionIdentity(name, kp.EncryptionIdentity); err != nil { t.Fatal(err) @@ -86,7 +105,7 @@ func newParty(t *testing.T, name string) *party { Name: name, EncPubKey: kp.EncryptionRecipient, SignPubKey: base64.StdEncoding.EncodeToString(kp.SigningPublicKey), - Fingerprint: kp.Fingerprint, + Fingerprint: fingerprint, Status: "active", } u, err := identity.Unlock(ks, info) @@ -106,141 +125,160 @@ func contactFor(alias string, sender *party) contacts.Contact { } } -type buildOpts struct { - profile format.Profile - private bool - signed bool - sender *party - recipient *party - headerFPOverride string // set a header SenderFingerprint that differs from the inner one - innerFPOverride string // set the inner/authoritative SenderFingerprint +func testMeta(sender *party, signed bool) format.Metadata { + return format.Metadata{ + SenderFingerprint: sender.kp.Fingerprint, + Timestamp: time.Now().UTC().Truncate(time.Second), + OriginalFilename: "test.bin", + IsSigned: signed, + } } -// buildContainer produces a serialized .icfx container exactly as the buffered -// encrypt flow does: a private profile seals inner metadata into the payload; a -// public profile leaves the payload bare. Then age-encrypt to the recipient, -// sign the ciphertext, and serialize. -func buildContainer(t *testing.T, filedata []byte, o buildOpts) []byte { +// encryptContainer produces a container through the real writer. +func encryptContainer(t *testing.T, sender, recipient *party, filedata []byte, profile format.Profile, signed bool) []byte { t.Helper() - if o.profile == 0 { - o.profile = format.ProfilePrivateBuffered + var signer *identity.Unlocked + if signed { + signer = sender.u } - innerFP := o.sender.kp.Fingerprint - if o.innerFPOverride != "" { - innerFP = o.innerFPOverride + var buf bytes.Buffer + if err := encrypt.EncryptStream(&buf, bytes.NewReader(filedata), []string{recipient.kp.EncryptionRecipient}, signer, testMeta(sender, signed), profile); err != nil { + t.Fatalf("EncryptStream: %v", err) } - meta := format.Metadata{SenderFingerprint: innerFP, OriginalFilename: "test.txt", IsSigned: o.signed} - - payloadPlain := filedata - if !o.profile.Public() { - enc, err := format.EncodePayload(meta, filedata) - if err != nil { - t.Fatalf("encode payload: %v", err) - } - payloadPlain = enc - } - ciphertext, err := crypto.Encrypt(payloadPlain, []string{o.recipient.kp.EncryptionRecipient}) - if err != nil { - t.Fatalf("encrypt: %v", err) - } - var sig []byte - if o.signed { - sig, err = crypto.Sign(ciphertext, o.sender.kp.SigningPrivateKey) - if err != nil { - t.Fatalf("sign: %v", err) - } + out := buf.Bytes() + if out[4] != byte(profile) { + t.Fatalf("expected profile byte %#x, got %#x", byte(profile), out[4]) } - c := &format.Container{Profile: o.profile, Payload: ciphertext, Signature: sig, Private: o.private} - if !o.private { - hdr := meta - if o.headerFPOverride != "" { - hdr.SenderFingerprint = o.headerFPOverride - } - c.Metadata = hdr - } - data, err := c.Serialize() + return out +} + +func decryptContainerBytes(t *testing.T, container []byte, u *identity.Unlocked, cl []contacts.Contact) ([]byte, VerifyResult) { + t.Helper() + var out bytes.Buffer + res, err := DecryptAndVerifyStream(bytes.NewReader(container), &out, u, cl) if err != nil { - t.Fatalf("serialize: %v", err) + t.Fatalf("DecryptAndVerifyStream: %v", err) } - return data + return out.Bytes(), res } -// --- tests ------------------------------------------------------------------ - -func TestUnsignedContainer(t *testing.T) { - sender, recipient := newParty(t, "sender"), newParty(t, "recipient") - file := []byte("hello world") - data := buildContainer(t, file, buildOpts{signed: false, sender: sender, recipient: recipient}) +// parts is a streaming container taken apart so tests can tamper with one +// field and put it back together. +type parts struct { + profile format.Profile + headerMeta []byte + payload []byte + signature []byte +} - res, err := DecryptAndVerify(data, recipient.u, nil) +func split(t *testing.T, data []byte) parts { + t.Helper() + profile, metaLen, err := format.ParseHeaderPrefix(data[:format.HeaderPrefixLen]) if err != nil { t.Fatal(err) } - if !bytes.Equal(res.Plaintext, file) { - t.Fatalf("plaintext mismatch: %q", res.Plaintext) - } - if res.Verify.Status != VerifyUnsigned { - t.Fatalf("want VerifyUnsigned, got %v", res.Verify.Status) - } + off := format.HeaderPrefixLen + p := parts{profile: profile, headerMeta: append([]byte{}, data[off:off+metaLen]...)} + off += metaLen + plen := int(binary.BigEndian.Uint64(data[off : off+8])) + off += 8 + p.payload = append([]byte{}, data[off:off+plen]...) + off += plen + slen := int(binary.BigEndian.Uint16(data[off : off+2])) + off += 2 + p.signature = append([]byte{}, data[off:off+slen]...) + return p } -func TestPrivateV2SignedContactMatch(t *testing.T) { - sender, recipient := newParty(t, "sender"), newParty(t, "recipient") - file := []byte("private v2 payload") - data := buildContainer(t, file, buildOpts{private: true, signed: true, sender: sender, recipient: recipient}) +func (p parts) join() []byte { + buf := append([]byte{}, format.MagicBytes...) + buf = append(buf, byte(p.profile)) + buf = binary.BigEndian.AppendUint16(buf, uint16(len(p.headerMeta))) + buf = append(buf, p.headerMeta...) + buf = binary.BigEndian.AppendUint64(buf, uint64(len(p.payload))) + buf = append(buf, p.payload...) + buf = binary.BigEndian.AppendUint16(buf, uint16(len(p.signature))) + return append(buf, p.signature...) +} - res, err := DecryptAndVerify(data, recipient.u, []contacts.Contact{contactFor("alice", sender)}) - if err != nil { - t.Fatal(err) +func digest(b []byte) []byte { + h := crypto.NewContainerDigest() + h.Write(b) + return h.Sum(nil) +} + +// --- round trips -------------------------------------------------------------- + +func TestPrivateSignedContactMatch(t *testing.T) { + sender, recipient := newParty(t, "sender"), newParty(t, "recipient") + file := []byte("private payload") + data := encryptContainer(t, sender, recipient, file, format.ProfilePrivate, true) + if bytes.Contains(data, []byte("test.bin")) || bytes.Contains(data, []byte(sender.kp.Fingerprint)) { + t.Fatal("private container leaked metadata") } - if !bytes.Equal(res.Plaintext, file) { - t.Fatalf("plaintext mismatch") + + plain, res := decryptContainerBytes(t, data, recipient.u, []contacts.Contact{contactFor("alice", sender)}) + if !bytes.Equal(plain, file) { + t.Fatalf("plaintext mismatch: %q", plain) } - if res.Verify.Status != VerifyOK || res.Verify.SignerAlias != "alice" || res.Verify.UsedRevokedKey { - t.Fatalf("want OK via contact alice, got %+v", res.Verify) + if res.Status != VerifyOK || res.SignerAlias != "alice" || res.UsedRevokedKey || res.SignerFP != sender.kp.Fingerprint { + t.Fatalf("want OK via contact alice, got %+v", res) } } -func TestPublicV2SignedContactMatch(t *testing.T) { +func TestPublicSignedContactMatch(t *testing.T) { sender, recipient := newParty(t, "sender"), newParty(t, "recipient") - file := []byte("public-meta v2") - data := buildContainer(t, file, buildOpts{private: false, signed: true, sender: sender, recipient: recipient}) + file := []byte("public-header payload") + data := encryptContainer(t, sender, recipient, file, format.ProfilePublic, true) + if !bytes.Contains(data, []byte("test.bin")) { + t.Fatal("public container should carry the advisory header") + } - res, err := DecryptAndVerify(data, recipient.u, []contacts.Contact{contactFor("bob", sender)}) - if err != nil { - t.Fatal(err) + plain, res := decryptContainerBytes(t, data, recipient.u, []contacts.Contact{contactFor("bob", sender)}) + if !bytes.Equal(plain, file) { + t.Fatal("plaintext mismatch") } - if res.Verify.Status != VerifyOK || res.Verify.SignerAlias != "bob" { - t.Fatalf("want OK via contact bob, got %+v", res.Verify) + if res.Status != VerifyOK || res.SignerAlias != "bob" { + t.Fatalf("want OK via contact bob, got %+v", res) } } func TestSelfSigned(t *testing.T) { - // Sender is also the recipient (encrypt-to-self); no contacts. self := newParty(t, "me") file := []byte("dear diary") - data := buildContainer(t, file, buildOpts{private: true, signed: true, sender: self, recipient: self}) + data := encryptContainer(t, self, self, file, format.ProfilePrivate, true) - res, err := DecryptAndVerify(data, self.u, nil) - if err != nil { - t.Fatal(err) + plain, res := decryptContainerBytes(t, data, self.u, nil) + if !bytes.Equal(plain, file) { + t.Fatal("plaintext mismatch") } - if res.Verify.Status != VerifyOK || res.Verify.SignerIdentity != "me" { - t.Fatalf("want OK via self identity, got %+v", res.Verify) + if res.Status != VerifyOK || res.SignerIdentity != "me" { + t.Fatalf("want OK via self, got %+v", res) } } -func TestUnverifiableUnknownSender(t *testing.T) { +func TestUnsigned(t *testing.T) { sender, recipient := newParty(t, "sender"), newParty(t, "recipient") - data := buildContainer(t, []byte("x"), buildOpts{private: true, signed: true, sender: sender, recipient: recipient}) - - // No contacts, recipient != sender → cannot resolve a signer key. - res, err := DecryptAndVerify(data, recipient.u, nil) - if err != nil { - t.Fatal(err) + file := []byte("no signature here") + for _, p := range []format.Profile{format.ProfilePrivate, format.ProfilePublic} { + data := encryptContainer(t, sender, recipient, file, p, false) + plain, res := decryptContainerBytes(t, data, recipient.u, nil) + if !bytes.Equal(plain, file) { + t.Fatal("plaintext mismatch") + } + if res.Status != VerifyUnsigned { + t.Fatalf("profile %#x: want VerifyUnsigned, got %+v", byte(p), res) + } } - if res.Verify.Status != VerifyUnverifiable || res.Verify.SignerFP != sender.kp.Fingerprint { - t.Fatalf("want Unverifiable with signer fp, got %+v", res.Verify) +} + +func TestUnknownSigner(t *testing.T) { + sender, recipient := newParty(t, "sender"), newParty(t, "recipient") + data := encryptContainer(t, sender, recipient, []byte("x"), format.ProfilePrivate, true) + + _, res := decryptContainerBytes(t, data, recipient.u, nil) // no contacts, recipient != sender + if res.Status != VerifyUnknownSigner || res.SignerFP != sender.kp.Fingerprint { + t.Fatalf("want UnknownSigner with signer fp, got %+v", res) } } @@ -252,7 +290,7 @@ func TestRevokedPreviousKey(t *testing.T) { } revokedAt := time.Date(2026, 8, 20, 0, 0, 0, 0, time.UTC) // File was signed with the OLD key; the contact has since rotated. - data := buildContainer(t, []byte("signed before rotation"), buildOpts{private: true, signed: true, sender: oldSender, recipient: recipient}) + data := encryptContainer(t, oldSender, recipient, []byte("signed before rotation"), format.ProfilePrivate, true) contact := contacts.Contact{ Alias: "carol", Fingerprint: newKP.Fingerprint, // current key @@ -264,105 +302,88 @@ func TestRevokedPreviousKey(t *testing.T) { }}, } - res, err := DecryptAndVerify(data, recipient.u, []contacts.Contact{contact}) - if err != nil { - t.Fatal(err) + _, res := decryptContainerBytes(t, data, recipient.u, []contacts.Contact{contact}) + if res.Status != VerifyOK || !res.UsedRevokedKey || res.SignerAlias != "carol" { + t.Fatalf("want OK via revoked key, got %+v", res) } - if res.Verify.Status != VerifyOK || !res.Verify.UsedRevokedKey || res.Verify.SignerAlias != "carol" { - t.Fatalf("want OK via revoked key, got %+v", res.Verify) - } - if !res.Verify.RevokedAt.Equal(revokedAt) { - t.Fatalf("want RevokedAt %v, got %v", revokedAt, res.Verify.RevokedAt) + if !res.RevokedAt.Equal(revokedAt) { + t.Fatalf("want RevokedAt %v, got %v", revokedAt, res.RevokedAt) } } -func TestTamperedSignature(t *testing.T) { +func TestInMemoryFormMatchesStream(t *testing.T) { sender, recipient := newParty(t, "sender"), newParty(t, "recipient") - data := buildContainer(t, []byte("payload"), buildOpts{private: true, signed: true, sender: sender, recipient: recipient}) - - // Flip a byte inside the trailing signature block. - tampered := make([]byte, len(data)) - copy(tampered, data) - tampered[len(tampered)-1] ^= 0xFF + file := []byte("same answer either way") + data := encryptContainer(t, sender, recipient, file, format.ProfilePrivate, true) - res, err := DecryptAndVerify(tampered, recipient.u, []contacts.Contact{contactFor("alice", sender)}) + res, err := DecryptAndVerify(data, recipient.u, []contacts.Contact{contactFor("alice", sender)}) if err != nil { t.Fatal(err) } - if res.Verify.Status != VerifyUnverifiable { - t.Fatalf("want Unverifiable for tampered sig, got %+v", res.Verify) + if !bytes.Equal(res.Plaintext, file) || res.Verify.Status != VerifyOK || res.Verify.SignerAlias != "alice" { + t.Fatalf("in-memory form: %+v", res.Verify) } } -func TestInnerMetaIsAuthoritative(t *testing.T) { - // A v2 public-meta container whose plaintext HEADER names a bogus signer, - // while the authenticated INNER metadata names the true sender. Verification - // must key off the inner metadata and succeed. +func TestPublicHeaderReadableWithoutDecrypt(t *testing.T) { sender, recipient := newParty(t, "sender"), newParty(t, "recipient") - data := buildContainer(t, []byte("trust the inside"), buildOpts{ - private: false, signed: true, sender: sender, recipient: recipient, - headerFPOverride: "00000000000000000000000000000000", - }) + data := encryptContainer(t, sender, recipient, []byte("scriptable"), format.ProfilePublic, true) - res, err := DecryptAndVerify(data, recipient.u, []contacts.Contact{contactFor("alice", sender)}) + sh, err := format.ParseStreamHeader(bytes.NewReader(data)) if err != nil { - t.Fatal(err) + t.Fatalf("ParseStreamHeader: %v", err) } - if res.Verify.Status != VerifyOK || res.Verify.SignerAlias != "alice" { - t.Fatalf("inner meta must win: got %+v", res.Verify) + if sh.Profile != format.ProfilePublic || sh.Private { + t.Fatalf("want public profile with header, got %#x private=%v", byte(sh.Profile), sh.Private) + } + if sh.HeaderMeta.OriginalFilename != "test.bin" || sh.HeaderMeta.SenderFingerprint != sender.kp.Fingerprint { + t.Fatalf("header metadata not readable without decrypting: %+v", sh.HeaderMeta) } } -func TestForgedInnerMetaFailsVerify(t *testing.T) { - // Inner metadata claims a DIFFERENT sender than the one who actually signed; - // the signature over the payload won't verify against that claimed key. - realSigner, recipient := newParty(t, "real"), newParty(t, "recipient") - impostor := newParty(t, "impostor") - data := buildContainer(t, []byte("who signed this?"), buildOpts{ - private: true, signed: true, sender: realSigner, recipient: recipient, - innerFPOverride: impostor.kp.Fingerprint, - }) +// --- writer contract --------------------------------------------------------- - res, err := DecryptAndVerify(data, recipient.u, []contacts.Contact{contactFor("impostor", impostor)}) - if err != nil { - t.Fatal(err) - } - if res.Verify.Status != VerifyUnverifiable { - t.Fatalf("forged inner-meta signer must not verify, got %+v", res.Verify) +func TestEncryptRejectsLegacyProfiles(t *testing.T) { + sender, recipient := newParty(t, "sender"), newParty(t, "recipient") + for _, p := range []format.Profile{format.LegacyProfilePublicBuffered, format.LegacyProfilePrivateBuffered, format.LegacyProfilePrivateStreaming, format.LegacyProfilePublicStreaming, format.Profile(0x07)} { + var buf bytes.Buffer + err := encrypt.EncryptStream(&buf, strings.NewReader("x"), []string{recipient.kp.EncryptionRecipient}, sender.u, testMeta(sender, true), p) + if err == nil { + t.Fatalf("profile %#x must not be writable", byte(p)) + } } } -func TestHeaderedV1Signed(t *testing.T) { +func TestSenderFingerprintFollowsSigner(t *testing.T) { sender, recipient := newParty(t, "sender"), newParty(t, "recipient") - file := []byte("legacy v1") - data := buildContainer(t, file, buildOpts{profile: format.ProfilePublicBuffered, private: false, signed: true, sender: sender, recipient: recipient}) + impostor := newParty(t, "impostor") - res, err := DecryptAndVerify(data, recipient.u, []contacts.Contact{contactFor("alice", sender)}) - if err != nil { + // An empty fingerprint is filled from the signer. + meta := testMeta(sender, true) + meta.SenderFingerprint = "" + var buf bytes.Buffer + if err := encrypt.EncryptStream(&buf, strings.NewReader("x"), []string{recipient.kp.EncryptionRecipient}, sender.u, meta, format.ProfilePrivate); err != nil { t.Fatal(err) } - if !bytes.Equal(res.Plaintext, file) { - t.Fatalf("v1 plaintext mismatch") + if _, res := decryptContainerBytes(t, buf.Bytes(), recipient.u, []contacts.Contact{contactFor("alice", sender)}); res.Status != VerifyOK || res.SignerFP != sender.kp.Fingerprint { + t.Fatalf("want OK with the signer's fingerprint sealed, got %+v", res) } - if res.Verify.Status != VerifyOK || res.Verify.SignerAlias != "alice" { - t.Fatalf("want OK via header FP, got %+v", res.Verify) + + // A fingerprint naming anyone but the signer is refused outright. + meta.SenderFingerprint = impostor.kp.Fingerprint + if err := encrypt.EncryptStream(&bytes.Buffer{}, strings.NewReader("x"), []string{recipient.kp.EncryptionRecipient}, sender.u, meta, format.ProfilePrivate); err == nil { + t.Fatal("a sealed fingerprint that is not the signer's must be refused") } } -func TestStrippedV1Private(t *testing.T) { +func TestWrongRecipientCannotDecrypt(t *testing.T) { sender, recipient := newParty(t, "sender"), newParty(t, "recipient") - file := []byte("stripped legacy") - data := buildContainer(t, file, buildOpts{profile: format.ProfilePublicBuffered, private: true, signed: true, sender: sender, recipient: recipient}) + stranger := newParty(t, "stranger") + data := encryptContainer(t, sender, recipient, []byte("secret"), format.ProfilePrivate, true) - res, err := DecryptAndVerify(data, recipient.u, []contacts.Contact{contactFor("alice", sender)}) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(res.Plaintext, file) { - t.Fatalf("stripped-v1 plaintext mismatch") - } - if res.Verify.Status != VerifyNoMetadata { - t.Fatalf("want NoMetadata for stripped v1, got %+v", res.Verify) + var out bytes.Buffer + if _, err := DecryptAndVerifyStream(bytes.NewReader(data), &out, stranger.u, nil); err == nil { + t.Fatal("want decrypt error for wrong recipient") } } @@ -371,14 +392,7 @@ func TestBadContainerErrors(t *testing.T) { if _, err := DecryptAndVerify([]byte("not an icfx container"), recipient.u, nil); err == nil { t.Fatal("want error for garbage input") } -} - -func TestWrongRecipientCannotDecrypt(t *testing.T) { - sender, recipient := newParty(t, "sender"), newParty(t, "recipient") - stranger := newParty(t, "stranger") - data := buildContainer(t, []byte("secret"), buildOpts{private: true, signed: true, sender: sender, recipient: recipient}) - - if _, err := DecryptAndVerify(data, stranger.u, nil); err == nil { - t.Fatal("want decrypt error for wrong recipient") + if _, err := DecryptAndVerify([]byte("ICFX\x09\x00\x00rest"), recipient.u, nil); !errors.Is(err, format.ErrUnsupportedProfile) { + t.Fatalf("unknown profile: want ErrUnsupportedProfile, got %v", err) } } diff --git a/decrypt/hostile_test.go b/decrypt/hostile_test.go new file mode 100644 index 0000000..a8748b4 --- /dev/null +++ b/decrypt/hostile_test.go @@ -0,0 +1,186 @@ +package decrypt + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "encoding/json" + "io" + "strings" + "testing" + + "github.com/instacryptio/icfx/contacts" + "github.com/instacryptio/icfx/crypto" + "github.com/instacryptio/icfx/encrypt" + "github.com/instacryptio/icfx/format" +) + +// These tests feed the decrypt path what a well-behaved writer never produces: +// hostile metadata, oversized lengths, corrupt key material. None of it may +// crash, exhaust memory, or name a sender the container cannot prove. + +// craft builds a container the way the writer does but seals the caller's +// metadata verbatim, signing (as sender) over the real digests when asked. +func craft(t *testing.T, sender, recipient *party, meta format.Metadata, file []byte, profile format.Profile, sign bool) []byte { + t.Helper() + metaJSON, err := json.Marshal(meta) + if err != nil { + t.Fatal(err) + } + var ct bytes.Buffer + ctDigest, ptDigest := crypto.NewContainerDigest(), crypto.NewContainerDigest() + ew, err := crypto.EncryptStream(io.MultiWriter(&ct, ctDigest), []string{recipient.kp.EncryptionRecipient}) + if err != nil { + t.Fatal(err) + } + var lenbuf [2]byte + binary.BigEndian.PutUint16(lenbuf[:], uint16(len(metaJSON))) + ew.Write(lenbuf[:]) + ew.Write(metaJSON) + if _, err := io.Copy(ew, io.TeeReader(bytes.NewReader(file), ptDigest)); err != nil { + t.Fatal(err) + } + if err := ew.Close(); err != nil { + t.Fatal(err) + } + p := parts{profile: profile, payload: ct.Bytes()} + if profile.Public() { + p.headerMeta = metaJSON + } + if sign { + if p.signature, err = crypto.Sign(crypto.SignedMessage(byte(profile), ptDigest.Sum(nil), ctDigest.Sum(nil)), sender.kp.SigningPrivateKey); err != nil { + t.Fatal(err) + } + } + return p.join() +} + +func TestHostileSealedFingerprintIsNeverNamed(t *testing.T) { + sender, recipient := newParty(t, "sender"), newParty(t, "recipient") + hostile := []string{ + "deadbeef)\nSignature verified (identity: Alice)", + "", // signed but nobody named + strings.Repeat("a", 31), + "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", + } + for _, fp := range hostile { + meta := testMeta(sender, true) + meta.SenderFingerprint = fp + data := craft(t, sender, recipient, meta, []byte("x"), format.ProfilePrivate, true) + _, res := decryptContainerBytes(t, data, recipient.u, []contacts.Contact{contactFor("alice", sender)}) + if res.Status != VerifyFailed || res.SignerFP != "" { + t.Fatalf("fingerprint %q: want VerifyFailed with no signer named, got %+v", fp, res) + } + } +} + +func TestUnsignedPublicHeaderTamperFails(t *testing.T) { + sender, recipient := newParty(t, "sender"), newParty(t, "recipient") + p := split(t, encryptContainer(t, sender, recipient, []byte("x"), format.ProfilePublic, false)) + var hm format.Metadata + if err := json.Unmarshal(p.headerMeta, &hm); err != nil { + t.Fatal(err) + } + hm.OriginalFilename = "invoice.pdf" + p.headerMeta, _ = json.Marshal(hm) + mustFail(t, p.join(), recipient, nil, "unsigned header tamper") + + // Extra JSON keys are a mismatch too: the header must be the sealed bytes. + q := split(t, encryptContainer(t, sender, recipient, []byte("x"), format.ProfilePublic, true)) + q.headerMeta = append(bytes.TrimSuffix(q.headerMeta, []byte("}")), []byte(`,"note":"x"}`)...) + mustFail(t, q.join(), recipient, []contacts.Contact{contactFor("alice", sender)}, "header with extra keys") +} + +func TestTrailingPayloadGarbageNeverVerifies(t *testing.T) { + sender, recipient := newParty(t, "sender"), newParty(t, "recipient") + p := split(t, encryptContainer(t, sender, recipient, []byte("x"), format.ProfilePrivate, true)) + p.payload = append(p.payload, []byte("trailing garbage inside the declared payload")...) + + var out bytes.Buffer + res, err := DecryptAndVerifyStream(bytes.NewReader(p.join()), &out, recipient.u, []contacts.Contact{contactFor("alice", sender)}) + if err == nil && res.Status == VerifyOK { + t.Fatalf("bytes appended inside the payload region must not verify: %+v", res) + } +} + +func TestHostileInnerMetaLength(t *testing.T) { + sender, recipient := newParty(t, "sender"), newParty(t, "recipient") + for _, claimed := range []uint16{65535, 3} { + var ct bytes.Buffer + ew, err := crypto.EncryptStream(&ct, []string{recipient.kp.EncryptionRecipient}) + if err != nil { + t.Fatal(err) + } + var lenbuf [2]byte + binary.BigEndian.PutUint16(lenbuf[:], claimed) + ew.Write(lenbuf[:]) + ew.Write([]byte("x")) // far shorter than claimed + ew.Close() + data := parts{profile: format.ProfilePrivate, payload: ct.Bytes()}.join() + var out bytes.Buffer + if _, err := DecryptAndVerifyStream(bytes.NewReader(data), &out, recipient.u, nil); err == nil { + t.Fatalf("metaLen %d over a short payload must error", claimed) + } + if out.Len() != 0 { + t.Fatalf("metaLen %d: nothing may be written before the metadata parses", claimed) + } + } + _ = sender +} + +func TestUndecodableContactKeyIsUnknownSigner(t *testing.T) { + sender, recipient := newParty(t, "sender"), newParty(t, "recipient") + data := encryptContainer(t, sender, recipient, []byte("x"), format.ProfilePrivate, true) + corrupt := contactFor("alice", sender) + corrupt.SignPubKey = "not base64 at all!!" + _, res := decryptContainerBytes(t, data, recipient.u, []contacts.Contact{corrupt}) + if res.Status != VerifyUnknownSigner { + t.Fatalf("a contact whose stored key cannot be decoded says nothing about the file; want UnknownSigner, got %+v", res) + } +} + +func TestDuplicateFingerprintContactsStaleFirst(t *testing.T) { + sender, recipient := newParty(t, "sender"), newParty(t, "recipient") + data := encryptContainer(t, sender, recipient, []byte("x"), format.ProfilePrivate, true) + otherKP, err := crypto.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + stale := contacts.Contact{Alias: "stale", Fingerprint: sender.kp.Fingerprint, SignPubKey: base64.StdEncoding.EncodeToString(otherKP.SigningPublicKey)} + real := contactFor("alice", sender) + real.Fingerprint = strings.ToUpper(real.Fingerprint) // case must not matter either + _, res := decryptContainerBytes(t, data, recipient.u, []contacts.Contact{stale, real}) + if res.Status != VerifyOK || res.SignerAlias != "alice" { + t.Fatalf("every contact carrying the fingerprint must be tried; got %+v", res) + } +} + +func TestLegacyBufferedSizeCap(t *testing.T) { + sender, recipient := newParty(t, "sender"), newParty(t, "recipient") + orig := maxLegacyBuffered + // The hybrid PQ age header alone is a few KiB, so the cap sits well above it. + maxLegacyBuffered = 16 << 10 + t.Cleanup(func() { maxLegacyBuffered = orig }) + + big := legacyContainer(t, sender, recipient, bytes.Repeat([]byte("A"), 64<<10), legacyOpts{profile: format.LegacyProfilePrivateBuffered}) + var out bytes.Buffer + if _, err := DecryptAndVerifyStream(bytes.NewReader(big), &out, recipient.u, nil); err == nil { + t.Fatal("a legacy buffered container over the cap must be rejected, not buffered") + } + small := legacyContainer(t, sender, recipient, []byte("fits"), legacyOpts{profile: format.LegacyProfilePrivateBuffered}) + if plain, res := decryptContainerBytes(t, small, recipient.u, nil); string(plain) != "fits" || res.Status != VerifyUnsigned { + t.Fatalf("under the cap must still decrypt: %q %+v", plain, res) + } +} + +func TestEncryptRefusesUnusableSignerFingerprint(t *testing.T) { + recipient := newParty(t, "recipient") + for _, fp := range []string{"", "not-a-fingerprint"} { + bad := newPartyWithFingerprint(t, "bad", fp) + var buf bytes.Buffer + err := encrypt.EncryptStream(&buf, strings.NewReader("x"), []string{recipient.kp.EncryptionRecipient}, bad.u, format.Metadata{IsSigned: true}, format.ProfilePrivate) + if err == nil { + t.Fatalf("signing with an identity whose fingerprint is %q must be refused", fp) + } + } +} diff --git a/decrypt/legacy_test.go b/decrypt/legacy_test.go new file mode 100644 index 0000000..0e21423 --- /dev/null +++ b/decrypt/legacy_test.go @@ -0,0 +1,132 @@ +package decrypt + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "testing" + + "github.com/instacryptio/icfx/contacts" + "github.com/instacryptio/icfx/crypto" + "github.com/instacryptio/icfx/format" +) + +// Legacy containers still decrypt so existing files stay readable, but their +// signatures — which bound only the ciphertext — are no longer honoured. These +// tests build each legacy layout by hand with a signature that WAS valid under +// its retired scheme, and check that the plaintext comes back while the +// verdict is VerifyFailed. + +// retiredStreamingTag is the domain prefix the retired streaming layouts +// signed under. It exists here only to prove such signatures are ignored. +const retiredStreamingTag = "ICFXv3\x00" + +type legacyOpts struct { + profile format.Profile + headered bool // write the plaintext header (public layouts only) + signed bool +} + +// legacyContainer builds a legacy container exactly as the retired writers +// did: sealed inner framing for the private layouts, bare file bytes for the +// public ones, a raw-ciphertext signature for buffered layouts and a tagged +// ciphertext-digest signature for streaming ones. +func legacyContainer(t *testing.T, sender, recipient *party, file []byte, o legacyOpts) []byte { + t.Helper() + meta := testMeta(sender, o.signed) + payloadPlain := file + if o.profile.SealsMetadata() { + var err error + if payloadPlain, err = format.EncodePayload(meta, file); err != nil { + t.Fatal(err) + } + } + ciphertext, err := crypto.Encrypt(payloadPlain, []string{recipient.kp.EncryptionRecipient}) + if err != nil { + t.Fatal(err) + } + var sig []byte + if o.signed { + msg := ciphertext + if o.profile.Streaming() { + msg = append([]byte(retiredStreamingTag), digest(ciphertext)...) + } + if sig, err = crypto.Sign(msg, sender.kp.SigningPrivateKey); err != nil { + t.Fatal(err) + } + } + var headerMeta []byte + if o.headered { + if headerMeta, err = json.Marshal(meta); err != nil { + t.Fatal(err) + } + } + buf := append([]byte{}, format.MagicBytes...) + buf = append(buf, byte(o.profile)) + buf = binary.BigEndian.AppendUint16(buf, uint16(len(headerMeta))) + buf = append(buf, headerMeta...) + switch { + case o.profile.Streaming(): + buf = binary.BigEndian.AppendUint64(buf, uint64(len(ciphertext))) + default: + buf = binary.BigEndian.AppendUint32(buf, uint32(len(ciphertext))) + } + buf = append(buf, ciphertext...) + buf = binary.BigEndian.AppendUint16(buf, uint16(len(sig))) + return append(buf, sig...) +} + +func TestLegacyDecryptsButNeverVerifies(t *testing.T) { + sender, recipient := newParty(t, "sender"), newParty(t, "recipient") + file := []byte("written by an older build") + cl := []contacts.Contact{contactFor("alice", sender)} // a key that WOULD have verified + + cases := []struct { + name string + o legacyOpts + }{ + {"public-buffered", legacyOpts{profile: format.LegacyProfilePublicBuffered, headered: true, signed: true}}, + {"private-buffered", legacyOpts{profile: format.LegacyProfilePrivateBuffered, signed: true}}, + {"private-streaming", legacyOpts{profile: format.LegacyProfilePrivateStreaming, signed: true}}, + {"public-streaming", legacyOpts{profile: format.LegacyProfilePublicStreaming, headered: true, signed: true}}, + {"public-streaming-stripped", legacyOpts{profile: format.LegacyProfilePublicStreaming, signed: true}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + plain, res := decryptContainerBytes(t, legacyContainer(t, sender, recipient, file, c.o), recipient.u, cl) + if !bytes.Equal(plain, file) { + t.Fatalf("plaintext mismatch: %q", plain) + } + if res.Status != VerifyFailed { + t.Fatalf("legacy signature must not be honoured, got %+v", res) + } + if c.o.headered || c.o.profile.SealsMetadata() { + if res.SignerFP != sender.kp.Fingerprint { + t.Fatalf("claimed signer must be reported: %+v", res) + } + } + }) + } +} + +func TestLegacyUnsignedStaysUnsigned(t *testing.T) { + sender, recipient := newParty(t, "sender"), newParty(t, "recipient") + file := []byte("never signed") + for _, p := range []format.Profile{format.LegacyProfilePublicBuffered, format.LegacyProfilePrivateBuffered, format.LegacyProfilePrivateStreaming, format.LegacyProfilePublicStreaming} { + plain, res := decryptContainerBytes(t, legacyContainer(t, sender, recipient, file, legacyOpts{profile: p, headered: p.Public()}), recipient.u, nil) + if !bytes.Equal(plain, file) { + t.Fatalf("profile %#x: plaintext mismatch", byte(p)) + } + if res.Status != VerifyUnsigned { + t.Fatalf("profile %#x: want VerifyUnsigned, got %+v", byte(p), res) + } + } +} + +func TestLegacyWrongRecipientCannotDecrypt(t *testing.T) { + sender, recipient, stranger := newParty(t, "sender"), newParty(t, "recipient"), newParty(t, "stranger") + data := legacyContainer(t, sender, recipient, []byte("secret"), legacyOpts{profile: format.LegacyProfilePrivateStreaming, signed: true}) + if _, err := DecryptAndVerifyStream(bytes.NewReader(data), &bytes.Buffer{}, stranger.u, nil); err == nil { + t.Fatal("want decrypt error for wrong recipient") + } +} diff --git a/decrypt/stream.go b/decrypt/stream.go index 96c97bf..1065670 100644 --- a/decrypt/stream.go +++ b/decrypt/stream.go @@ -1,6 +1,7 @@ package decrypt import ( + "bytes" "encoding/binary" "encoding/json" "fmt" @@ -11,16 +12,28 @@ import ( "github.com/instacryptio/icfx/crypto" "github.com/instacryptio/icfx/format" "github.com/instacryptio/icfx/identity" + "github.com/instacryptio/icfx/validate" ) -// DecryptAndVerifyStream streams an .icfx container from src to dst, verifying -// any signature. v3 containers stream in CONSTANT MEMORY (a large file never -// materializes in RAM); v1/v2 containers fall back to the buffered -// DecryptAndVerify (their uint32 payload length caps them at ~4 GiB anyway). -// Verification is advisory, exactly as DecryptAndVerify — the plaintext is -// written to dst regardless of the outcome; callers inspect the VerifyResult. +// maxLegacyBuffered caps how much of a legacy buffered container (profiles +// 0x01/0x02, whole ciphertext in memory) is read. The profile byte is +// attacker-controlled, so without a cap any oversized file could be steered +// onto the buffered path and exhaust memory. Real legacy files are far below +// this; the layout itself topped out at 4 GiB. +var maxLegacyBuffered int64 = 512 << 20 + +// DecryptAndVerifyStream decrypts the .icfx container in src to dst in constant +// memory and verifies its signature. The plaintext is written regardless of +// the outcome and the verdict is returned afterwards — for the current +// profiles it cannot be known sooner, because the signature binds the +// plaintext digest and that completes only with the last byte. Callers that +// must not expose unverified plaintext therefore write dst to a temporary +// location and promote it only once the VerifyResult is acceptable. +// +// Legacy profiles decrypt as they always did; if they claim a signature the +// result is VerifyFailed without any check, since their scheme never bound the +// plaintext and is no longer honoured. func DecryptAndVerifyStream(src io.ReadSeeker, dst io.Writer, u *identity.Unlocked, contactList []contacts.Contact) (VerifyResult, error) { - // Peek the version, then rewind. head := make([]byte, format.HeaderPrefixLen) if _, err := io.ReadFull(src, head); err != nil { return VerifyResult{}, fmt.Errorf("reading ICFX header: %w", err) @@ -32,64 +45,132 @@ func DecryptAndVerifyStream(src io.ReadSeeker, dst io.Writer, u *identity.Unlock if _, err := src.Seek(0, io.SeekStart); err != nil { return VerifyResult{}, err } - - // Buffered profiles (ProfilePublicBuffered / ProfilePrivateBuffered): their - // uint32 length caps size, so buffering is the pre-existing model. - if !profile.Streaming() { - data, err := io.ReadAll(src) - if err != nil { - return VerifyResult{}, err - } - res, err := DecryptAndVerify(data, u, contactList) - if err != nil { - return VerifyResult{}, err - } - if _, err := dst.Write(res.Plaintext); err != nil { - return VerifyResult{}, err - } - return res.Verify, nil + switch { + case !profile.Streaming(): + return decryptLegacyBuffered(src, dst, u) + case profile.Legacy(): + return decryptLegacyStreaming(src, dst, u) } + return decryptContainer(src, dst, u, contactList) +} - // --- Streaming profiles: two passes over the ciphertext, constant memory --- +// decryptContainer handles ProfilePrivate / ProfilePublic: one pass over the +// payload that hashes the ciphertext going into age and the plaintext coming +// out, then verifies crypto.SignedMessage against the signer named by the +// sealed metadata. +func decryptContainer(src io.ReadSeeker, dst io.Writer, u *identity.Unlocked, contactList []contacts.Contact) (VerifyResult, error) { sh, err := format.ParseStreamHeader(src) if err != nil { - return VerifyResult{}, fmt.Errorf("parsing streaming container: %w", err) + return VerifyResult{}, fmt.Errorf("parsing container: %w", err) + } + if sh.Profile == format.ProfilePrivate && !sh.Private { + return VerifyResult{}, fmt.Errorf("parsing container: private profile carries a header: %w", format.ErrInvalidFormat) } - - // PASS 1: streaming SHA-512 over the ciphertext payload → digest. if _, err := src.Seek(sh.PayloadStart, io.SeekStart); err != nil { return VerifyResult{}, err } - h := crypto.NewV3Digest() - if _, err := io.CopyN(h, src, sh.PayloadLen); err != nil { + + ctDigest := crypto.NewContainerDigest() + ptDigest := crypto.NewContainerDigest() + payload := io.LimitReader(src, sh.PayloadLen) + pr, err := u.DecryptStream(io.TeeReader(payload, ctDigest)) + if err != nil { + return VerifyResult{}, fmt.Errorf("decrypting payload: %w", err) + } + meta, sealedRaw, err := readInnerMeta(pr) + if err != nil { + return VerifyResult{}, fmt.Errorf("reading inner metadata: %w", err) + } + if _, err := io.Copy(dst, io.TeeReader(pr, ptDigest)); err != nil { + return VerifyResult{}, fmt.Errorf("writing plaintext: %w", err) + } + // age stops reading at its final chunk; hash whatever payload bytes it + // left unread so the digest covers the payload exactly as written. + if _, err := io.Copy(ctDigest, payload); err != nil { return VerifyResult{}, fmt.Errorf("hashing payload: %w", err) } - digest := h.Sum(nil) + return verdict(sh, meta, sealedRaw, ptDigest.Sum(nil), ctDigest.Sum(nil), u, contactList), nil +} - // Public profile (ProfilePublicStreaming): the signer fingerprint is in the - // plaintext header, so we can verify BEFORE decrypting; the payload is the - // bare file bytes (no inner metadata), streamed straight to dst. - if sh.Profile.Public() { - verify := VerifyResult{Status: VerifyUnsigned} - if sh.HeaderMeta.IsSigned && len(sh.Signature) > 0 { - verify = verifySignature(crypto.V3SignedMessage(digest), sh.Signature, sh.HeaderMeta.SenderFingerprint, u, contactList) - } - if _, err := src.Seek(sh.PayloadStart, io.SeekStart); err != nil { - return VerifyResult{}, err +// verdict decides the VerifyResult for a decrypted container. The sealed +// metadata is the only authority on whether the container is signed and by +// whom; every inconsistency between it and what is on the wire is a failure. +// A sealed sender fingerprint is reported only when it is well-formed — it is +// a string the file's author chose, so nothing else may ever be shown as a +// sender. +func verdict(sh *format.StreamHeader, meta format.Metadata, sealedRaw, plaintextDigest, ciphertextDigest []byte, u *identity.Unlocked, contactList []contacts.Contact) VerifyResult { + claimed := claimedSigner(meta) + failed := VerifyResult{Status: VerifyFailed, SignerFP: claimed} + // The advisory header, when present, must be the sealed bytes verbatim — + // signed or not: the writer emits the same JSON in both places. + if !sh.Private && !bytes.Equal(sh.HeaderRaw, sealedRaw) { + return failed + } + if !meta.IsSigned { + if len(sh.Signature) > 0 { + // Nobody may bolt a signature onto a file its author left unsigned. + return failed } - pr, err := u.DecryptStream(io.LimitReader(src, sh.PayloadLen)) + return VerifyResult{Status: VerifyUnsigned} + } + switch { + case claimed == "": + return failed // signed but names no well-formed signer: malformed + case len(sh.Signature) == 0: + return failed // signature stripped + } + return verifySignature(crypto.SignedMessage(byte(sh.Profile), plaintextDigest, ciphertextDigest), sh.Signature, claimed, u, contactList) +} + +// claimedSigner returns the sealed sender fingerprint if it has the shape of +// a real fingerprint, else "" — so a malformed or hostile value is never +// resolved against, reported, or displayed. +func claimedSigner(meta format.Metadata) string { + if validate.ValidateFingerprint(meta.SenderFingerprint) != nil { + return "" + } + return meta.SenderFingerprint +} + +// decryptLegacyBuffered decrypts a LegacyProfilePublicBuffered / +// LegacyProfilePrivateBuffered container (uint32 payload length, whole +// ciphertext in memory, bounded by maxLegacyBuffered). +func decryptLegacyBuffered(src io.Reader, dst io.Writer, u *identity.Unlocked) (VerifyResult, error) { + data, err := io.ReadAll(io.LimitReader(src, maxLegacyBuffered+1)) + if err != nil { + return VerifyResult{}, err + } + if int64(len(data)) > maxLegacyBuffered { + return VerifyResult{}, fmt.Errorf("parsing ICFX container: legacy buffered container exceeds %d bytes: %w", maxLegacyBuffered, format.ErrInvalidFormat) + } + c, err := format.Deserialize(data) + if err != nil { + return VerifyResult{}, fmt.Errorf("parsing ICFX container: %w", err) + } + plaintext, err := u.Decrypt(c.Payload) + if err != nil { + return VerifyResult{}, fmt.Errorf("decrypting payload: %w", err) + } + meta, filedata := c.Metadata, plaintext + if c.Profile.SealsMetadata() { + meta, filedata, err = format.DecodePayload(plaintext) if err != nil { - return VerifyResult{}, fmt.Errorf("decrypting payload: %w", err) + return VerifyResult{}, fmt.Errorf("parsing inner metadata: %w", err) } - if _, err := io.Copy(dst, pr); err != nil { - return VerifyResult{}, fmt.Errorf("writing plaintext: %w", err) - } - return verify, nil } + if _, err := dst.Write(filedata); err != nil { + return VerifyResult{}, fmt.Errorf("writing plaintext: %w", err) + } + return legacyVerdict(meta, c.Signature), nil +} - // Private profile (ProfilePrivateStreaming). PASS 2: decrypt the payload. - // Read the inner metadata (signer fingerprint) off the front, verify the - // signature (before any file bytes reach dst), then stream the file bytes out. +// decryptLegacyStreaming decrypts a LegacyProfilePrivateStreaming / +// LegacyProfilePublicStreaming container in constant memory. +func decryptLegacyStreaming(src io.ReadSeeker, dst io.Writer, u *identity.Unlocked) (VerifyResult, error) { + sh, err := format.ParseStreamHeader(src) + if err != nil { + return VerifyResult{}, fmt.Errorf("parsing container: %w", err) + } if _, err := src.Seek(sh.PayloadStart, io.SeekStart); err != nil { return VerifyResult{}, err } @@ -97,40 +178,48 @@ func DecryptAndVerifyStream(src io.ReadSeeker, dst io.Writer, u *identity.Unlock if err != nil { return VerifyResult{}, fmt.Errorf("decrypting payload: %w", err) } - meta, err := readInnerMeta(pr) - if err != nil { - return VerifyResult{}, fmt.Errorf("reading inner metadata: %w", err) - } - - verify := VerifyResult{Status: VerifyUnsigned} - if meta.IsSigned && len(sh.Signature) > 0 { - // Reuse the shared verify policy (contact→revoked→self); the message is - // the domain-separated digest instead of the whole ciphertext. - verify = verifySignature(crypto.V3SignedMessage(digest), sh.Signature, meta.SenderFingerprint, u, contactList) + meta := sh.HeaderMeta + if sh.Profile.SealsMetadata() { + meta, _, err = readInnerMeta(pr) + if err != nil { + return VerifyResult{}, fmt.Errorf("reading inner metadata: %w", err) + } } - if _, err := io.Copy(dst, pr); err != nil { return VerifyResult{}, fmt.Errorf("writing plaintext: %w", err) } - return verify, nil + return legacyVerdict(meta, sh.Signature), nil +} + +// legacyVerdict: legacy signatures bound only the ciphertext and are no longer +// honoured, so anything that claims to be signed fails. The claimed signer +// fingerprint (if the layout carried a well-formed one) is reported so the +// caller can say who the file said it was from. +func legacyVerdict(meta format.Metadata, signature []byte) VerifyResult { + if !meta.IsSigned && len(signature) == 0 { + return VerifyResult{Status: VerifyUnsigned} + } + return VerifyResult{Status: VerifyFailed, SignerFP: claimedSigner(meta)} } // readInnerMeta reads the uint16-length-prefixed inner metadata off the front of -// a decrypted v2/v3 payload stream, leaving r positioned at the file bytes. -func readInnerMeta(r io.Reader) (format.Metadata, error) { +// a decrypted payload stream, leaving r positioned at the file bytes. It returns +// the parsed metadata and the raw JSON bytes (for byte-exact comparison with an +// advisory header). +func readInnerMeta(r io.Reader) (format.Metadata, []byte, error) { var lenbuf [2]byte if _, err := io.ReadFull(r, lenbuf[:]); err != nil { - return format.Metadata{}, err + return format.Metadata{}, nil, err } mbuf := make([]byte, int(binary.BigEndian.Uint16(lenbuf[:]))) if _, err := io.ReadFull(r, mbuf); err != nil { - return format.Metadata{}, err + return format.Metadata{}, nil, err } var meta format.Metadata if err := json.Unmarshal(mbuf, &meta); err != nil { - return format.Metadata{}, err + return format.Metadata{}, nil, err } - return meta, nil + return meta, mbuf, nil } // decryptToWriter dispatches a container (ICFX magic) vs a bare-age file, both @@ -150,10 +239,11 @@ func decryptToWriter(isContainer bool, src io.ReadSeeker, dst io.Writer, u *iden return VerifyResult{Status: VerifyUnsigned}, nil } -// DecryptFile streams the .icfx (or bare-age) file at inPath to outPath. v3 -// containers decrypt in constant memory. It overwrites outPath; callers enforce -// their own overwrite/force policy and atomic-rename if needed. Returns the -// verification outcome (VerifyUnsigned for bare-age input). +// DecryptFile streams the .icfx (or bare-age) file at inPath to outPath in +// constant memory. It overwrites outPath; callers enforce their own overwrite +// policy. The verdict arrives after the plaintext is on disk (see +// DecryptAndVerifyStream), so callers that gate on it should pass a temporary +// outPath and promote it themselves. Returns VerifyUnsigned for bare-age input. func DecryptFile(inPath, outPath string, u *identity.Unlocked, contactList []contacts.Contact) (VerifyResult, error) { in, err := os.Open(inPath) if err != nil { @@ -169,12 +259,17 @@ func DecryptFile(inPath, outPath string, u *identity.Unlocked, contactList []con return VerifyResult{}, err } - // 0600: decrypted plaintext may be sensitive; don't leave it world-readable - // (os.Create would use 0666 & umask, typically 0644). + // 0600: decrypted plaintext may be sensitive; don't leave it world-readable. + // The mode in OpenFile only applies when the file is created, so an + // existing outPath is tightened explicitly. out, err := os.OpenFile(outPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return VerifyResult{}, err } + if err := out.Chmod(0600); err != nil { + out.Close() + return VerifyResult{}, fmt.Errorf("restricting output permissions: %w", err) + } verify, decErr := decryptToWriter(string(magic) == string(format.MagicBytes), in, out, u, contactList) closeErr := out.Close() diff --git a/decrypt/stream_test.go b/decrypt/stream_test.go index bc5c8a8..b931f65 100644 --- a/decrypt/stream_test.go +++ b/decrypt/stream_test.go @@ -15,175 +15,19 @@ import ( "github.com/instacryptio/icfx/contacts" "github.com/instacryptio/icfx/encrypt" "github.com/instacryptio/icfx/format" - "github.com/instacryptio/icfx/identity" ) -// encryptStreaming produces a streaming container via the encrypt path. private -// selects ProfilePrivateStreaming (metadata sealed inside), else -// ProfilePublicStreaming (metadata in a readable plaintext header). -func encryptStreaming(t *testing.T, sender, recipient *party, filedata []byte, private, signed bool) []byte { - t.Helper() - profile := format.ProfilePrivateStreaming - if !private { - profile = format.ProfilePublicStreaming - } - meta := format.Metadata{ - SenderFingerprint: sender.kp.Fingerprint, - Timestamp: time.Now().UTC(), - OriginalFilename: "test.bin", - IsSigned: signed, - } - var signer *identity.Unlocked - if signed { - signer = sender.u - } - var buf bytes.Buffer - if err := encrypt.EncryptStream(&buf, bytes.NewReader(filedata), []string{recipient.kp.EncryptionRecipient}, signer, meta, profile); err != nil { - t.Fatalf("EncryptStream: %v", err) - } - out := buf.Bytes() - if len(out) < 5 || out[4] != byte(profile) { - t.Fatalf("expected profile byte %#x, got %x", byte(profile), out[:5]) - } - if !private && !bytes.Contains(out, []byte("test.bin")) { - t.Fatal("public container should carry the plaintext header filename") - } - if private && bytes.Contains(out, []byte("test.bin")) { - t.Fatal("private container leaked the filename") - } - return out -} - -func decryptV3(t *testing.T, container []byte, u *identity.Unlocked, cl []contacts.Contact) ([]byte, VerifyResult) { - t.Helper() - var out bytes.Buffer - res, err := DecryptAndVerifyStream(bytes.NewReader(container), &out, u, cl) - if err != nil { - t.Fatalf("DecryptAndVerifyStream: %v", err) - } - return out.Bytes(), res -} - -func TestV3PrivateSignedContactMatch(t *testing.T) { - sender, recipient := newParty(t, "sender"), newParty(t, "recipient") - file := []byte("private v3 payload") - data := encryptStreaming(t, sender, recipient, file, true, true) - - plain, res := decryptV3(t, data, recipient.u, []contacts.Contact{contactFor("alice", sender)}) - if !bytes.Equal(plain, file) { - t.Fatalf("plaintext mismatch: %q", plain) - } - if res.Status != VerifyOK || res.SignerAlias != "alice" || res.UsedRevokedKey { - t.Fatalf("want OK via contact alice, got %+v", res) - } -} - -func TestV3PublicSignedContactMatch(t *testing.T) { - sender, recipient := newParty(t, "sender"), newParty(t, "recipient") - file := []byte("public-meta v3") - data := encryptStreaming(t, sender, recipient, file, false, true) - - plain, res := decryptV3(t, data, recipient.u, []contacts.Contact{contactFor("bob", sender)}) - if !bytes.Equal(plain, file) { - t.Fatal("plaintext mismatch") - } - if res.Status != VerifyOK || res.SignerAlias != "bob" { - t.Fatalf("want OK via contact bob, got %+v", res) - } -} - -func TestV3SelfSigned(t *testing.T) { - self := newParty(t, "me") - file := []byte("dear v3 diary") - data := encryptStreaming(t, self, self, file, true, true) - - plain, res := decryptV3(t, data, self.u, nil) - if !bytes.Equal(plain, file) { - t.Fatal("plaintext mismatch") - } - if res.Status != VerifyOK || res.SignerIdentity != "me" { - t.Fatalf("want OK via self, got %+v", res) - } -} - -func TestV3Unsigned(t *testing.T) { - sender, recipient := newParty(t, "sender"), newParty(t, "recipient") - file := []byte("no signature here") - data := encryptStreaming(t, sender, recipient, file, true, false) - - plain, res := decryptV3(t, data, recipient.u, nil) - if !bytes.Equal(plain, file) { - t.Fatal("plaintext mismatch") - } - if res.Status != VerifyUnsigned { - t.Fatalf("want VerifyUnsigned, got %+v", res) - } -} - -func TestV3UnknownSender(t *testing.T) { - sender, recipient := newParty(t, "sender"), newParty(t, "recipient") - data := encryptStreaming(t, sender, recipient, []byte("x"), true, true) - - _, res := decryptV3(t, data, recipient.u, nil) // no contacts, recipient != sender - if res.Status != VerifyUnverifiable || res.SignerFP != sender.kp.Fingerprint { - t.Fatalf("want Unverifiable with signer fp, got %+v", res) - } -} - -func TestV3TamperedCiphertext(t *testing.T) { - sender, recipient := newParty(t, "sender"), newParty(t, "recipient") - data := encryptStreaming(t, sender, recipient, []byte("tamper me"), true, true) - - // Flip a byte in the middle (the payload region). - tampered := make([]byte, len(data)) - copy(tampered, data) - tampered[len(tampered)/2] ^= 0xFF - - var out bytes.Buffer - res, err := DecryptAndVerifyStream(bytes.NewReader(tampered), &out, recipient.u, []contacts.Contact{contactFor("alice", sender)}) - // age may reject the tampered ciphertext (decrypt error) OR the signature - // fails — either way it must NOT report VerifyOK. - if err == nil && res.Status == VerifyOK { - t.Fatalf("tampered ciphertext must not verify OK, got %+v", res) - } -} - -func TestV3TamperedSignature(t *testing.T) { - sender, recipient := newParty(t, "sender"), newParty(t, "recipient") - data := encryptStreaming(t, sender, recipient, []byte("payload"), true, true) - - tampered := make([]byte, len(data)) - copy(tampered, data) - tampered[len(tampered)-1] ^= 0xFF // last byte is inside the signature - - _, res := decryptV3(t, tampered, recipient.u, []contacts.Contact{contactFor("alice", sender)}) - if res.Status != VerifyUnverifiable { - t.Fatalf("want Unverifiable for tampered sig, got %+v", res) - } -} - -func TestV3WrongRecipientCannotDecrypt(t *testing.T) { - sender, recipient := newParty(t, "sender"), newParty(t, "recipient") - stranger := newParty(t, "stranger") - data := encryptStreaming(t, sender, recipient, []byte("secret"), true, true) - - var out bytes.Buffer - if _, err := DecryptAndVerifyStream(bytes.NewReader(data), &out, stranger.u, nil); err == nil { - t.Fatal("want decrypt error for wrong recipient") - } -} - -// TestV3LargerPayload round-trips a multi-MB payload to exercise the streaming +// TestLargerPayload round-trips a multi-MB payload to exercise the streaming // path across several age chunks. -func TestV3LargerPayload(t *testing.T) { +func TestLargerPayload(t *testing.T) { sender, recipient := newParty(t, "sender"), newParty(t, "recipient") file := make([]byte, 5<<20) // 5 MiB if _, err := rand.Read(file); err != nil { t.Fatal(err) } - data := encryptStreaming(t, sender, recipient, file, true, true) + data := encryptContainer(t, sender, recipient, file, format.ProfilePrivate, true) - plain, res := decryptV3(t, data, recipient.u, []contacts.Contact{contactFor("alice", sender)}) + plain, res := decryptContainerBytes(t, data, recipient.u, []contacts.Contact{contactFor("alice", sender)}) if !bytes.Equal(plain, file) { t.Fatal("large payload mismatch") } @@ -192,116 +36,48 @@ func TestV3LargerPayload(t *testing.T) { } } -// TestV3StreamAcceptsV2 confirms the streaming entrypoint transparently handles a -// v2 container via the buffered fallback (cross-version). -func TestV3StreamAcceptsV2(t *testing.T) { - sender, recipient := newParty(t, "sender"), newParty(t, "recipient") - file := []byte("legacy v2 through the stream API") - v2 := buildContainer(t, file, buildOpts{private: true, signed: true, sender: sender, recipient: recipient}) - - plain, res := decryptV3(t, v2, recipient.u, []contacts.Contact{contactFor("alice", sender)}) - if !bytes.Equal(plain, file) { - t.Fatal("v2 via stream API: plaintext mismatch") - } - if res.Status != VerifyOK || res.SignerAlias != "alice" { - t.Fatalf("v2 via stream API: want OK, got %+v", res) - } -} - -// TestV4HeaderReadableWithoutDecrypt is the point of the public streaming -// profile: a script can read the metadata (filename, sender fingerprint) from -// the plaintext header without ever decrypting the payload. -func TestV4HeaderReadableWithoutDecrypt(t *testing.T) { - sender, recipient := newParty(t, "sender"), newParty(t, "recipient") - data := encryptStreaming(t, sender, recipient, []byte("scriptable"), false, true) // public streaming (v4) - - sh, err := format.ParseStreamHeader(bytes.NewReader(data)) - if err != nil { - t.Fatalf("ParseStreamHeader: %v", err) - } - if sh.Profile != format.ProfilePublicStreaming || sh.Private { - t.Fatalf("want public streaming profile, got %#x private=%v", byte(sh.Profile), sh.Private) - } - if sh.HeaderMeta.OriginalFilename != "test.bin" || sh.HeaderMeta.SenderFingerprint != sender.kp.Fingerprint { - t.Fatalf("header metadata not readable without decrypting: %+v", sh.HeaderMeta) - } -} - -// TestV4TamperedSignature: a public-streaming container verifies against the -// header fingerprint; a tampered signature must not verify. -func TestV4TamperedSignature(t *testing.T) { - sender, recipient := newParty(t, "sender"), newParty(t, "recipient") - data := encryptStreaming(t, sender, recipient, []byte("payload"), false, true) // public streaming - - tampered := make([]byte, len(data)) - copy(tampered, data) - tampered[len(tampered)-1] ^= 0xFF // last byte is inside the signature - - _, res := decryptV3(t, tampered, recipient.u, []contacts.Contact{contactFor("alice", sender)}) - if res.Status != VerifyUnverifiable { - t.Fatalf("want Unverifiable for tampered v4 sig, got %+v", res) - } -} - -// TestProfileMatrixRoundTrip exercises all four profiles end-to-end through the -// high-level EncryptFile/DecryptFile — including the buffered write path — and -// pins the visibility contract: public profiles expose the filename on the wire, -// private profiles do not; all four round-trip and verify. -func TestProfileMatrixRoundTrip(t *testing.T) { +// TestFileRoundTrip exercises the path-based wrappers for both profiles and +// pins the visibility contract: only ProfilePublic exposes the filename. +func TestFileRoundTrip(t *testing.T) { sender, recipient := newParty(t, "sender"), newParty(t, "recipient") - file := []byte("round-trip across every profile") - cases := []struct { - name string - profile format.Profile - }{ - {"public-buffered", format.ProfilePublicBuffered}, - {"private-buffered", format.ProfilePrivateBuffered}, - {"private-streaming", format.ProfilePrivateStreaming}, - {"public-streaming", format.ProfilePublicStreaming}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - dir := t.TempDir() - inPath := filepath.Join(dir, "in.bin") - encPath := filepath.Join(dir, "in.icfx") - outPath := filepath.Join(dir, "out.bin") - if err := os.WriteFile(inPath, file, 0o600); err != nil { - t.Fatal(err) - } - meta := format.Metadata{SenderFingerprint: sender.kp.Fingerprint, OriginalFilename: "in.bin", IsSigned: true} - if err := encrypt.EncryptFile(inPath, encPath, []string{recipient.kp.EncryptionRecipient}, sender.u, meta, c.profile); err != nil { - t.Fatalf("EncryptFile: %v", err) - } - - enc, err := os.ReadFile(encPath) - if err != nil { - t.Fatal(err) - } - if enc[4] != byte(c.profile) { - t.Fatalf("wire profile byte = %#x, want %#x", enc[4], byte(c.profile)) - } - if c.profile.Public() && !bytes.Contains(enc, []byte("in.bin")) { - t.Fatal("public profile should expose the filename in the header") - } - if !c.profile.Public() && bytes.Contains(enc, []byte("in.bin")) { - t.Fatal("private profile leaked the filename") - } + file := []byte("round-trip through the file API") + for _, profile := range []format.Profile{format.ProfilePrivate, format.ProfilePublic} { + dir := t.TempDir() + inPath := filepath.Join(dir, "in.bin") + encPath := filepath.Join(dir, "in.icfx") + outPath := filepath.Join(dir, "out.bin") + if err := os.WriteFile(inPath, file, 0o600); err != nil { + t.Fatal(err) + } + meta := format.Metadata{OriginalFilename: "in.bin", IsSigned: true} + if err := encrypt.EncryptFile(inPath, encPath, []string{recipient.kp.EncryptionRecipient}, sender.u, meta, profile); err != nil { + t.Fatalf("EncryptFile: %v", err) + } + enc, err := os.ReadFile(encPath) + if err != nil { + t.Fatal(err) + } + if enc[4] != byte(profile) { + t.Fatalf("wire profile byte = %#x, want %#x", enc[4], byte(profile)) + } + if bytes.Contains(enc, []byte("in.bin")) != profile.Public() { + t.Fatalf("profile %#x: filename visibility wrong", byte(profile)) + } - res, err := DecryptFile(encPath, outPath, recipient.u, []contacts.Contact{contactFor("alice", sender)}) - if err != nil { - t.Fatalf("DecryptFile: %v", err) - } - if res.Status != VerifyOK || res.SignerAlias != "alice" { - t.Fatalf("want OK via alice, got %+v", res) - } - out, err := os.ReadFile(outPath) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(out, file) { - t.Fatal("round-trip plaintext mismatch") - } - }) + res, err := DecryptFile(encPath, outPath, recipient.u, []contacts.Contact{contactFor("alice", sender)}) + if err != nil { + t.Fatalf("DecryptFile: %v", err) + } + if res.Status != VerifyOK || res.SignerAlias != "alice" { + t.Fatalf("want OK via alice, got %+v", res) + } + out, err := os.ReadFile(outPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(out, file) { + t.Fatal("round-trip plaintext mismatch") + } } } @@ -323,11 +99,10 @@ func (z *zeroReader) Read(p []byte) (int, error) { return int(k), nil } -// TestV3ConstantMemory proves the point of v3: encrypting AND decrypting a file -// far larger than any reasonable buffer keeps live heap bounded (streaming), -// never holding the whole file in RAM. If either side buffered the file, peak -// heap would approach the file size and blow the threshold. -func TestV3ConstantMemory(t *testing.T) { +// TestConstantMemory proves encrypting AND decrypting a file far larger than +// any reasonable buffer keeps live heap bounded — the digests over plaintext +// and ciphertext are computed on the way through, never by buffering. +func TestConstantMemory(t *testing.T) { if testing.Short() { t.Skip("skipping large-file memory test in -short mode") } @@ -377,8 +152,8 @@ func TestV3ConstantMemory(t *testing.T) { } }() - meta := format.Metadata{SenderFingerprint: sender.kp.Fingerprint, OriginalFilename: "in.bin", IsSigned: true} - if err := encrypt.EncryptFile(inPath, encPath, []string{recipient.kp.EncryptionRecipient}, sender.u, meta, format.ProfilePrivateStreaming); err != nil { + meta := format.Metadata{OriginalFilename: "in.bin", IsSigned: true} + if err := encrypt.EncryptFile(inPath, encPath, []string{recipient.kp.EncryptionRecipient}, sender.u, meta, format.ProfilePrivate); err != nil { t.Fatalf("EncryptFile: %v", err) } res, err := DecryptFile(encPath, outPath, recipient.u, []contacts.Contact{contactFor("alice", sender)}) diff --git a/encrypt/stream.go b/encrypt/stream.go index ef21419..b1654d1 100644 --- a/encrypt/stream.go +++ b/encrypt/stream.go @@ -1,10 +1,8 @@ -// Package encrypt produces .icfx containers across all four format profiles -// (see format.Profile). EncryptStream writes the streaming profiles -// (ProfilePrivateStreaming / ProfilePublicStreaming), signing a ciphertext -// digest so encryption runs in constant memory — large files never materialize -// in RAM. EncryptFile is the high-level entry point: it dispatches to the -// streaming path or the buffered path (ProfilePublicBuffered / -// ProfilePrivateBuffered) by profile. +// Package encrypt writes .icfx containers. EncryptStream is the single writer: +// it stream-encrypts the plaintext for the recipients, seals the metadata +// inside the ciphertext, and signs a message binding the plaintext, the +// ciphertext and the profile (crypto.SignedMessage) — in constant memory, so +// file size never matters. EncryptFile is the path-based convenience wrapper. package encrypt import ( @@ -18,22 +16,48 @@ import ( "github.com/instacryptio/icfx/crypto" "github.com/instacryptio/icfx/format" "github.com/instacryptio/icfx/identity" + "github.com/instacryptio/icfx/validate" ) -// EncryptStream writes a streaming .icfx container to dst: it stream-encrypts -// the plaintext read from src for the hybrid PQ `recipients` (one ciphertext -// any one of them can decrypt), and (when meta.IsSigned) signs a SHA-512 digest -// of the ciphertext with `signer`. The ciphertext is streamed to a temp file and -// hashed on the way, so only the small digest is ever signed — constant memory -// regardless of file size. +// EncryptStream writes an .icfx container to dst: it stream-encrypts the +// plaintext read from src for the hybrid PQ `recipients` (one ciphertext any +// one of them can decrypt), seals meta inside the ciphertext ahead of the file +// bytes, and (when meta.IsSigned) signs crypto.SignedMessage — the profile plus +// SHA-512 digests of the plaintext and of the ciphertext — with `signer`. Both +// digests are computed on the way through, so only the small message is ever +// signed and memory stays constant regardless of file size. // -// profile must be a streaming profile. ProfilePrivateStreaming seals the -// metadata inside the payload (inner framing) and leaves the header empty; -// ProfilePublicStreaming carries the metadata in the plaintext header and writes -// the bare file bytes as the payload (no inner framing). +// profile must be ProfilePrivate or ProfilePublic; the legacy profiles are +// decrypt-only. ProfilePublic additionally writes an advisory copy of meta to +// the plaintext header. +// +// When signing, meta.SenderFingerprint is set from signer if empty and must +// match it if given — the sealed fingerprint is what recipients resolve the +// signer by, so it can never name anyone but the key that signed. func EncryptStream(dst io.Writer, src io.Reader, recipients []string, signer *identity.Unlocked, meta format.Metadata, profile format.Profile) error { - if !profile.Streaming() { - return fmt.Errorf("EncryptStream requires a streaming profile, got %#x", byte(profile)) + switch profile { + case format.ProfilePrivate, format.ProfilePublic: + default: + return fmt.Errorf("EncryptStream: profile %#x is not writable (legacy profiles are decrypt-only)", byte(profile)) + } + if meta.IsSigned { + if signer == nil { + return fmt.Errorf("meta.IsSigned set but no signer provided") + } + // The sealed fingerprint is how recipients resolve the signer, so it + // must be the signer's and must be a real fingerprint — a container + // naming nobody could never verify. + fp := signer.Fingerprint() + if err := validate.ValidateFingerprint(fp); err != nil { + return fmt.Errorf("signer has no usable fingerprint: %w", err) + } + switch meta.SenderFingerprint { + case "": + meta.SenderFingerprint = fp + case fp: + default: + return fmt.Errorf("meta.SenderFingerprint %q does not match the signer %q", meta.SenderFingerprint, fp) + } } metaJSON, err := json.Marshal(meta) if err != nil { @@ -42,11 +66,9 @@ func EncryptStream(dst io.Writer, src io.Reader, recipients []string, signer *id if len(metaJSON) > 65535 { return fmt.Errorf("metadata too large: %d bytes", len(metaJSON)) } - if meta.IsSigned && signer == nil { - return fmt.Errorf("meta.IsSigned set but no signer provided") - } - // 1. Stream-encrypt src into a temp ciphertext file, hashing the ciphertext. + // 1. Stream-encrypt src into a temp ciphertext file, hashing the plaintext + // on the way in and the ciphertext on the way out. tmp, err := os.CreateTemp(config.TempDir(), "icfx-enc-*") if err != nil { return fmt.Errorf("creating temp file: %w", err) @@ -54,26 +76,25 @@ func EncryptStream(dst io.Writer, src io.Reader, recipients []string, signer *id defer os.Remove(tmp.Name()) defer tmp.Close() - digest := crypto.NewV3Digest() - ew, err := crypto.EncryptStream(io.MultiWriter(tmp, digest), recipients) + ctDigest := crypto.NewContainerDigest() + ptDigest := crypto.NewContainerDigest() + ew, err := crypto.EncryptStream(io.MultiWriter(tmp, ctDigest), recipients) if err != nil { return fmt.Errorf("starting encryption: %w", err) } - // Private profiles frame the metadata ahead of the file bytes, inside the - // encrypted payload: uint16 metaLen || metaJSON || filedata. Public profiles - // write only the file bytes (metadata rides in the plaintext header). + // The sealed metadata leads the plaintext: uint16 metaLen || metaJSON || + // file bytes. Only the file bytes feed the plaintext digest — the framing + // is inside the ciphertext, which the ciphertext digest covers. var lenbuf [8]byte - if !profile.Public() { - binary.BigEndian.PutUint16(lenbuf[:2], uint16(len(metaJSON))) - if _, err := ew.Write(lenbuf[:2]); err != nil { - return fmt.Errorf("writing inner metadata length: %w", err) - } - if _, err := ew.Write(metaJSON); err != nil { - return fmt.Errorf("writing inner metadata: %w", err) - } + binary.BigEndian.PutUint16(lenbuf[:2], uint16(len(metaJSON))) + if _, err := ew.Write(lenbuf[:2]); err != nil { + return fmt.Errorf("writing inner metadata length: %w", err) + } + if _, err := ew.Write(metaJSON); err != nil { + return fmt.Errorf("writing inner metadata: %w", err) } - if _, err := io.Copy(ew, src); err != nil { + if _, err := io.Copy(ew, io.TeeReader(src, ptDigest)); err != nil { return fmt.Errorf("encrypting payload: %w", err) } if err := ew.Close(); err != nil { // flushes the age final chunk into the temp file @@ -86,10 +107,10 @@ func EncryptStream(dst io.Writer, src io.Reader, recipients []string, signer *id } payloadLen := fi.Size() - // 2. Sign the digest (small, fixed-size message). + // 2. Sign the message binding profile, plaintext and ciphertext. var signature []byte if meta.IsSigned { - signature, err = signer.Sign(crypto.V3SignedMessage(digest.Sum(nil))) + signature, err = signer.Sign(crypto.SignedMessage(byte(profile), ptDigest.Sum(nil), ctDigest.Sum(nil))) if err != nil { return fmt.Errorf("signing: %w", err) } @@ -99,8 +120,8 @@ func EncryptStream(dst io.Writer, src io.Reader, recipients []string, signer *id } // 3. Assemble the container: magic|profile|headerMetaLen|[headerMeta]| - // payloadLen(u64)|payload|sigLen|sig. Public profiles expose the header - // metadata; private profiles leave it empty. + // payloadLen(u64)|payload|sigLen|sig. Only ProfilePublic carries the + // advisory header copy. var headerMeta []byte if profile.Public() { headerMeta = metaJSON @@ -142,61 +163,9 @@ func EncryptStream(dst io.Writer, src io.Reader, recipients []string, signer *id return nil } -// encryptBuffered writes a buffered .icfx container (ProfilePublicBuffered / -// ProfilePrivateBuffered): the whole ciphertext is held in memory and the -// signature covers all of it. Reuses the format/crypto primitives. Suited to -// small data / compatibility; large inputs should use a streaming profile. -func encryptBuffered(dst io.Writer, src io.Reader, recipients []string, signer *identity.Unlocked, meta format.Metadata, profile format.Profile) error { - if meta.IsSigned && signer == nil { - return fmt.Errorf("meta.IsSigned set but no signer provided") - } - plaintext, err := io.ReadAll(src) - if err != nil { - return fmt.Errorf("reading input: %w", err) - } - - // Private profiles seal the metadata inside the payload; public profiles - // carry only the file bytes (metadata rides in the plaintext header). - payloadPlain := plaintext - if !profile.Public() { - payloadPlain, err = format.EncodePayload(meta, plaintext) - if err != nil { - return fmt.Errorf("framing inner metadata: %w", err) - } - } - - ciphertext, err := crypto.Encrypt(payloadPlain, recipients) - if err != nil { - return fmt.Errorf("encrypting: %w", err) - } - - var signature []byte - if meta.IsSigned { - signature, err = signer.Sign(ciphertext) // buffered signs the whole ciphertext - if err != nil { - return fmt.Errorf("signing: %w", err) - } - } - - c := &format.Container{Profile: profile, Payload: ciphertext, Signature: signature, Private: true} - if profile.Public() { - c.Metadata = meta - c.Private = false - } - data, err := c.Serialize() - if err != nil { - return fmt.Errorf("serializing container: %w", err) - } - if _, err := dst.Write(data); err != nil { - return err - } - return nil -} - -// EncryptFile encrypts inPath to a .icfx container at outPath in the given -// profile, dispatching to the streaming or buffered writer. It overwrites -// outPath; callers enforce their own overwrite policy. meta.OriginalFilename and -// the other fields are the caller's to populate. +// EncryptFile encrypts inPath to an .icfx container at outPath in the given +// profile. It overwrites outPath; callers enforce their own overwrite policy. +// meta.OriginalFilename and the other fields are the caller's to populate. func EncryptFile(inPath, outPath string, recipients []string, signer *identity.Unlocked, meta format.Metadata, profile format.Profile) error { in, err := os.Open(inPath) if err != nil { @@ -209,11 +178,7 @@ func EncryptFile(inPath, outPath string, recipients []string, signer *identity.U return err } - write := EncryptStream - if !profile.Streaming() { - write = encryptBuffered - } - encErr := write(out, in, recipients, signer, meta, profile) + encErr := EncryptStream(out, in, recipients, signer, meta, profile) closeErr := out.Close() if encErr != nil { _ = os.Remove(outPath) diff --git a/format/format_test.go b/format/format_test.go index 7434930..a465823 100644 --- a/format/format_test.go +++ b/format/format_test.go @@ -1,82 +1,247 @@ package format import ( + "bytes" + "encoding/binary" + "encoding/json" "errors" + "math" + "strings" "testing" "time" ) -func TestSerializeDeserialize(t *testing.T) { - original := &Container{ - Profile: ProfilePublicBuffered, - Metadata: Metadata{ - SenderFingerprint: "a1b2c3d4e5f6abcd", - Timestamp: time.Now().Truncate(time.Second), - OriginalFilename: "test.txt", - IsSigned: true, - }, - Payload: []byte("encrypted-payload-data"), - Signature: []byte("signature-bytes"), +func testMeta() Metadata { + return Metadata{ + SenderFingerprint: "a1b2c3d4e5f6abcd", + Timestamp: time.Now().Truncate(time.Second), + OriginalFilename: "secret-report.pdf", + IsSigned: true, } +} + +// legacyBuffered hand-builds a legacy buffered container (uint32 payload +// length). meta == nil leaves the header block empty. +func legacyBuffered(t *testing.T, profile Profile, meta *Metadata, payload, sig []byte) []byte { + t.Helper() + var metaJSON []byte + if meta != nil { + var err error + if metaJSON, err = json.Marshal(meta); err != nil { + t.Fatal(err) + } + } + buf := append([]byte{}, MagicBytes...) + buf = append(buf, byte(profile)) + buf = binary.BigEndian.AppendUint16(buf, uint16(len(metaJSON))) + buf = append(buf, metaJSON...) + buf = binary.BigEndian.AppendUint32(buf, uint32(len(payload))) + buf = append(buf, payload...) + buf = binary.BigEndian.AppendUint16(buf, uint16(len(sig))) + return append(buf, sig...) +} + +// streaming hand-builds a streaming-layout container (uint64 payload length). +func streaming(t *testing.T, profile Profile, meta *Metadata, payload, sig []byte) []byte { + t.Helper() + var metaJSON []byte + if meta != nil { + var err error + if metaJSON, err = json.Marshal(meta); err != nil { + t.Fatal(err) + } + } + buf := append([]byte{}, MagicBytes...) + buf = append(buf, byte(profile)) + buf = binary.BigEndian.AppendUint16(buf, uint16(len(metaJSON))) + buf = append(buf, metaJSON...) + buf = binary.BigEndian.AppendUint64(buf, uint64(len(payload))) + buf = append(buf, payload...) + buf = binary.BigEndian.AppendUint16(buf, uint16(len(sig))) + return append(buf, sig...) +} + +func TestProfilePredicates(t *testing.T) { + cases := []struct { + p Profile + valid, legacy, public, streaming, sealed bool + }{ + {LegacyProfilePublicBuffered, true, true, true, false, false}, + {LegacyProfilePrivateBuffered, true, true, false, false, true}, + {LegacyProfilePrivateStreaming, true, true, false, true, true}, + {LegacyProfilePublicStreaming, true, true, true, true, false}, + {ProfilePrivate, true, false, false, true, true}, + {ProfilePublic, true, false, true, true, true}, + {Profile(0x00), false, false, false, false, false}, + {Profile(0x07), false, false, false, false, false}, + } + for _, c := range cases { + got := [5]bool{c.p.Valid(), c.p.Legacy(), c.p.Public(), c.p.Streaming(), c.p.SealsMetadata()} + want := [5]bool{c.valid, c.legacy, c.public, c.streaming, c.sealed} + if got != want { + t.Errorf("profile %#x: valid/legacy/public/streaming/sealed = %v, want %v", byte(c.p), got, want) + } + } +} - data, err := original.Serialize() +func TestEncodeDecodePayload(t *testing.T) { + filedata := []byte("the actual file bytes") + inner, err := EncodePayload(testMeta(), filedata) if err != nil { - t.Fatalf("Serialize() error: %v", err) + t.Fatalf("EncodePayload: %v", err) } + meta, out, err := DecodePayload(inner) + if err != nil { + t.Fatalf("DecodePayload: %v", err) + } + if !meta.Equal(testMeta()) { + t.Fatalf("inner metadata round-trip: %+v", meta) + } + if !bytes.Equal(out, filedata) { + t.Fatal("file bytes round-trip mismatch") + } + if _, _, err := DecodePayload([]byte{0xFF, 0xFF, 0x00}); !errors.Is(err, ErrInvalidFormat) { + t.Fatalf("truncated inner metadata: want ErrInvalidFormat, got %v", err) + } +} - parsed, err := Deserialize(data) +func TestMetadataEqual(t *testing.T) { + a := testMeta() + if !a.Equal(a) { + t.Fatal("metadata must equal itself") + } + b := a + b.OriginalFilename = "other.pdf" + if a.Equal(b) { + t.Fatal("different filename must not be equal") + } + c := a + c.SenderFingerprint = "0000000000000000" + if a.Equal(c) { + t.Fatal("different fingerprint must not be equal") + } +} + +func TestDeserializeLegacyBuffered(t *testing.T) { + meta := testMeta() + payload, sig := []byte("age-ciphertext"), []byte("signature-bytes") + + headered, err := Deserialize(legacyBuffered(t, LegacyProfilePublicBuffered, &meta, payload, sig)) if err != nil { - t.Fatalf("Deserialize() error: %v", err) + t.Fatalf("Deserialize headered: %v", err) + } + if headered.Profile != LegacyProfilePublicBuffered || headered.Private || !headered.Metadata.Equal(meta) { + t.Fatalf("headered parse: %+v", headered) + } + if !bytes.Equal(headered.Payload, payload) || !bytes.Equal(headered.Signature, sig) { + t.Fatal("payload/signature mismatch") } - if parsed.Profile != original.Profile { - t.Errorf("Profile = %#x, want %#x", byte(parsed.Profile), byte(original.Profile)) + private, err := Deserialize(legacyBuffered(t, LegacyProfilePrivateBuffered, nil, payload, nil)) + if err != nil { + t.Fatalf("Deserialize private: %v", err) } - if parsed.Metadata.SenderFingerprint != original.Metadata.SenderFingerprint { - t.Errorf("SenderFingerprint = %q, want %q", parsed.Metadata.SenderFingerprint, original.Metadata.SenderFingerprint) + if !private.Private || private.Metadata.SenderFingerprint != "" || len(private.Signature) != 0 { + t.Fatalf("private parse: %+v", private) } - if parsed.Metadata.OriginalFilename != original.Metadata.OriginalFilename { - t.Errorf("OriginalFilename = %q, want %q", parsed.Metadata.OriginalFilename, original.Metadata.OriginalFilename) + if strings.Contains(string(legacyBuffered(t, LegacyProfilePrivateBuffered, nil, payload, nil)), "secret-report") { + t.Fatal("private container leaked metadata plaintext") } - if parsed.Metadata.IsSigned != original.Metadata.IsSigned { - t.Errorf("IsSigned = %v, want %v", parsed.Metadata.IsSigned, original.Metadata.IsSigned) +} + +func TestDeserializeRejects(t *testing.T) { + meta := testMeta() + for _, p := range []Profile{LegacyProfilePrivateStreaming, LegacyProfilePublicStreaming, ProfilePrivate, ProfilePublic} { + if _, err := Deserialize(streaming(t, p, &meta, []byte("p"), nil)); !errors.Is(err, ErrUnsupportedProfile) { + t.Errorf("profile %#x: want ErrUnsupportedProfile, got %v", byte(p), err) + } + } + if _, err := Deserialize([]byte("NOPE\x01\x00\x00")); !errors.Is(err, ErrInvalidMagic) { + t.Errorf("bad magic: want ErrInvalidMagic, got %v", err) } - if string(parsed.Payload) != string(original.Payload) { - t.Errorf("Payload mismatch") + if _, err := Deserialize([]byte("ICFX")); !errors.Is(err, ErrInvalidFormat) { + t.Errorf("too short: want ErrInvalidFormat, got %v", err) } - if string(parsed.Signature) != string(original.Signature) { - t.Errorf("Signature mismatch") + // A declared payload length past the end must be rejected, not panic. + whole := legacyBuffered(t, LegacyProfilePrivateBuffered, nil, []byte("hello"), nil) + if _, err := Deserialize(whole[:len(whole)-4]); err == nil { + t.Fatal("Deserialize accepted a truncated container; want error") } } -func TestSerializeDeserializeUnsigned(t *testing.T) { - original := &Container{ - Profile: ProfilePublicBuffered, - Metadata: Metadata{ - SenderFingerprint: "abcdef0123456789", - Timestamp: time.Now().Truncate(time.Second), - OriginalFilename: "unsigned.txt", - IsSigned: false, - }, - Payload: []byte("payload"), - Signature: nil, +func TestParseHeaderPrefix(t *testing.T) { + meta := testMeta() + data := streaming(t, ProfilePublic, &meta, []byte("p"), nil) + profile, metaLen, err := ParseHeaderPrefix(data[:HeaderPrefixLen]) + if err != nil { + t.Fatalf("ParseHeaderPrefix: %v", err) + } + if profile != ProfilePublic || metaLen == 0 { + t.Fatalf("prefix parse: profile=%#x metaLen=%d", byte(profile), metaLen) + } + if _, _, err := ParseHeaderPrefix([]byte("BOGUS12")); !errors.Is(err, ErrInvalidMagic) { + t.Fatal("bogus magic must fail") + } + if _, _, err := ParseHeaderPrefix([]byte("ICFX\x09\x00\x00")); !errors.Is(err, ErrUnsupportedProfile) { + t.Fatal("unknown profile must fail") } - data, err := original.Serialize() + head := append([]byte{}, data[:HeaderPrefixLen]...) + ZeroMetaLen(head) + if _, metaLen, _ := ParseHeaderPrefix(head); metaLen != 0 { + t.Fatal("ZeroMetaLen must clear the header length") + } +} + +func TestParseStreamHeader(t *testing.T) { + meta := testMeta() + payload, sig := []byte("age-ciphertext-bytes"), []byte("sig") + + pub, err := ParseStreamHeader(bytes.NewReader(streaming(t, ProfilePublic, &meta, payload, sig))) if err != nil { - t.Fatalf("Serialize() error: %v", err) + t.Fatalf("ParseStreamHeader public: %v", err) + } + if pub.Profile != ProfilePublic || pub.Private || !pub.HeaderMeta.Equal(meta) { + t.Fatalf("public header: %+v", pub) + } + if pub.PayloadLen != int64(len(payload)) || !bytes.Equal(pub.Signature, sig) { + t.Fatalf("public offsets: %+v", pub) + } + data := streaming(t, ProfilePublic, &meta, payload, sig) + if !bytes.Equal(data[pub.PayloadStart:pub.PayloadStart+pub.PayloadLen], payload) { + t.Fatal("PayloadStart does not locate the payload") } - parsed, err := Deserialize(data) + priv, err := ParseStreamHeader(bytes.NewReader(streaming(t, ProfilePrivate, nil, payload, nil))) if err != nil { - t.Fatalf("Deserialize() error: %v", err) + t.Fatalf("ParseStreamHeader private: %v", err) } + if !priv.Private || priv.Signature != nil || priv.HeaderMeta.SenderFingerprint != "" { + t.Fatalf("private header: %+v", priv) + } + + if _, err := ParseStreamHeader(bytes.NewReader(legacyBuffered(t, LegacyProfilePrivateBuffered, nil, payload, nil))); !errors.Is(err, ErrUnsupportedProfile) { + t.Fatalf("buffered layout through ParseStreamHeader: want ErrUnsupportedProfile, got %v", err) + } +} - if parsed.Metadata.IsSigned { - t.Error("IsSigned should be false") +func TestParseStreamHeaderRejectsHostileLengths(t *testing.T) { + meta := testMeta() + data := streaming(t, ProfilePublic, &meta, []byte("short payload"), nil) + sh, err := ParseStreamHeader(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + lenOff := sh.PayloadStart - 8 + for _, claimed := range []uint64{math.MaxUint64, math.MaxInt64, math.MaxInt64 - 1, 1 << 40} { + hostile := append([]byte{}, data...) + binary.BigEndian.PutUint64(hostile[lenOff:lenOff+8], claimed) + if _, err := ParseStreamHeader(bytes.NewReader(hostile)); err == nil { + t.Fatalf("PayloadLen %d must be rejected", claimed) + } } - if len(parsed.Signature) != 0 { - t.Error("Signature should be empty") + if !bytes.Equal(sh.HeaderRaw, data[HeaderPrefixLen:HeaderPrefixLen+len(sh.HeaderRaw)]) { + t.Fatal("HeaderRaw must be the header bytes verbatim") } } @@ -86,17 +251,15 @@ func TestDetect(t *testing.T) { data []byte want Format }{ - {"ICFX", []byte("ICFX\x01rest-of-data"), FormatICFX}, + {"ICFX", []byte("ICFX\x05rest-of-data"), FormatICFX}, {"age", []byte("age-encryption.org/v1\n..."), FormatAge}, {"unknown", []byte("random data"), FormatUnknown}, {"empty", []byte{}, FormatUnknown}, {"short", []byte("IC"), FormatUnknown}, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := Detect(tt.data) - if got != tt.want { + if got := Detect(tt.data); got != tt.want { t.Errorf("Detect() = %v, want %v", got, tt.want) } }) @@ -112,24 +275,9 @@ func TestFormatString(t *testing.T) { {FormatAge, "age"}, {FormatUnknown, "unknown"}, } - for _, tt := range tests { if got := tt.f.String(); got != tt.want { t.Errorf("Format(%d).String() = %q, want %q", tt.f, got, tt.want) } } } - -func TestDeserializeInvalidMagic(t *testing.T) { - _, err := Deserialize([]byte("NOPE\x01\x00\x00")) - if !errors.Is(err, ErrInvalidMagic) { - t.Errorf("expected ErrInvalidMagic, got %v", err) - } -} - -func TestDeserializeTooShort(t *testing.T) { - _, err := Deserialize([]byte("ICFX")) - if !errors.Is(err, ErrInvalidFormat) { - t.Errorf("expected ErrInvalidFormat, got %v", err) - } -} diff --git a/format/icfx.go b/format/icfx.go index 2315bab..fcc4c43 100644 --- a/format/icfx.go +++ b/format/icfx.go @@ -14,62 +14,99 @@ var ( MagicBytes = []byte("ICFX") ErrInvalidMagic = errors.New("invalid ICFX magic bytes") ErrInvalidFormat = errors.New("invalid ICFX format") - ErrUnsupportedVersion = errors.New("unsupported ICFX version") + ErrUnsupportedProfile = errors.New("unsupported ICFX profile") ) -// A Profile is a container's format profile — a deliberate point in the 2×2 -// design space of ICFX containers, chosen along two orthogonal axes: +// A Profile is the byte after the magic that selects a container's layout. // -// Buffered (whole-ciphertext sig) Streaming (digest sig) -// Public (header) ProfilePublicBuffered (0x01) ProfilePublicStreaming (0x04) -// Private (sealed) ProfilePrivateBuffered (0x02) ProfilePrivateStreaming (0x03) +// Two profiles are written. Both stream (uint64 payload length, so encrypt and +// decrypt run in constant memory) and both seal the metadata — sender +// fingerprint, filename, timestamp — inside the encrypted payload, where the +// signature covers it: // -// Visibility — Public keeps metadata (filename, sender fingerprint) in a -// plaintext header: readable without decrypting, but NOT covered by the -// signature (advisory only) — suited to scripting / low-threat automation. -// Private seals metadata inside the encrypted payload, where the signature -// authenticates it and nothing but the ciphertext size is exposed. Default. +// ProfilePrivate (0x05) nothing but the ciphertext size is visible on the +// wire. The default. +// ProfilePublic (0x06) additionally carries an ADVISORY plaintext copy of +// the metadata in the header so tooling can read the +// filename without decrypting. The copy is never used +// to resolve the signer, and one that disagrees with +// the sealed metadata fails verification. // -// Signature model — Buffered signs the whole ciphertext (simple, but must hold -// it in memory to sign/verify; uint32 payload length caps it near 4 GiB). -// Streaming signs a SHA-512 digest of the ciphertext, so encrypt and verify run -// in constant memory regardless of file size (uint64 payload length). Default; -// prefer it for anything that might be large. -// -// The wire byte order is historical (0x01–0x03 predate 0x04); read intent from -// the names, not the numbers. +// The Legacy* profiles are the layouts written before the signature covered +// the plaintext. They still decrypt so existing files stay readable, but their +// signatures are no longer honoured: a signed legacy container reports +// VerifyFailed and the caller decides whether to keep the plaintext. type Profile byte const ( - // ProfilePublicBuffered (0x01): public header-only metadata + whole-ciphertext - // signature. The original ICFX format — lean and transparent; for small data - // / compatibility. Verification resolves the signer from the header. - ProfilePublicBuffered Profile = 0x01 - // ProfilePrivateBuffered (0x02): metadata sealed & authenticated inside the - // payload (EncodePayload) + whole-ciphertext signature. Private, but must - // buffer the ciphertext to sign/verify. - ProfilePrivateBuffered Profile = 0x02 - // ProfilePrivateStreaming (0x03): private like ProfilePrivateBuffered, but - // signs a ciphertext digest so encrypt/decrypt run in constant memory — large - // files never materialize in RAM. The default profile. - ProfilePrivateStreaming Profile = 0x03 - // ProfilePublicStreaming (0x04): public header-only metadata like - // ProfilePublicBuffered, but digest-signed for constant-memory streaming — - // transparent metadata on large files. - ProfilePublicStreaming Profile = 0x04 + // LegacyProfilePublicBuffered (0x01): plaintext header metadata, bare file + // bytes as payload, uint32 payload length. Decrypt only. + LegacyProfilePublicBuffered Profile = 0x01 + // LegacyProfilePrivateBuffered (0x02): metadata sealed inside the payload + // (EncodePayload framing), uint32 payload length. Decrypt only. + LegacyProfilePrivateBuffered Profile = 0x02 + // LegacyProfilePrivateStreaming (0x03): sealed metadata, uint64 payload + // length. Decrypt only. + LegacyProfilePrivateStreaming Profile = 0x03 + // LegacyProfilePublicStreaming (0x04): plaintext header metadata, bare file + // bytes as payload, uint64 payload length. Decrypt only. + LegacyProfilePublicStreaming Profile = 0x04 + // ProfilePrivate (0x05): sealed metadata, empty header. The default. + ProfilePrivate Profile = 0x05 + // ProfilePublic (0x06): sealed metadata plus an advisory header copy. + ProfilePublic Profile = 0x06 ) -// Public reports whether the profile keeps metadata in a plaintext header -// (readable without decrypting, not signature-authenticated) rather than sealed -// inside the encrypted payload. +// Valid reports whether p is a profile this build can parse. +func (p Profile) Valid() bool { + switch p { + case LegacyProfilePublicBuffered, LegacyProfilePrivateBuffered, + LegacyProfilePrivateStreaming, LegacyProfilePublicStreaming, + ProfilePrivate, ProfilePublic: + return true + } + return false +} + +// Legacy reports whether p is a decrypt-only layout whose signature (if any) +// is no longer honoured. +func (p Profile) Legacy() bool { + switch p { + case LegacyProfilePublicBuffered, LegacyProfilePrivateBuffered, + LegacyProfilePrivateStreaming, LegacyProfilePublicStreaming: + return true + } + return false +} + +// Public reports whether the profile carries metadata in a plaintext header. +// For ProfilePublic that copy is advisory; the sealed copy is authoritative. func (p Profile) Public() bool { - return p == ProfilePublicBuffered || p == ProfilePublicStreaming + switch p { + case LegacyProfilePublicBuffered, LegacyProfilePublicStreaming, ProfilePublic: + return true + } + return false } -// Streaming reports whether the profile signs a ciphertext digest — constant -// memory, uint64 payload length — rather than the whole ciphertext. +// Streaming reports whether the profile uses a uint64 payload length and the +// streaming container layout (rather than the legacy uint32 buffered one). func (p Profile) Streaming() bool { - return p == ProfilePrivateStreaming || p == ProfilePublicStreaming + switch p { + case LegacyProfilePrivateStreaming, LegacyProfilePublicStreaming, ProfilePrivate, ProfilePublic: + return true + } + return false +} + +// SealsMetadata reports whether the encrypted payload begins with the inner +// metadata framing (uint16 metaLen || metaJSON) ahead of the file bytes. +func (p Profile) SealsMetadata() bool { + switch p { + case LegacyProfilePrivateBuffered, LegacyProfilePrivateStreaming, ProfilePrivate, ProfilePublic: + return true + } + return false } // Metadata holds the .icfx container metadata. @@ -80,112 +117,69 @@ type Metadata struct { IsSigned bool `json:"is_signed"` } -// Container represents a parsed .icfx file. Serialize/Deserialize handle the -// buffered profiles (ProfilePublicBuffered, ProfilePrivateBuffered); the -// streaming profiles use the encrypt/decrypt streaming paths. +// Equal reports whether two metadata records describe the same container. +// Used to check a ProfilePublic advisory header against the sealed copy. +func (m Metadata) Equal(o Metadata) bool { + return m.SenderFingerprint == o.SenderFingerprint && + m.OriginalFilename == o.OriginalFilename && + m.IsSigned == o.IsSigned && + m.Timestamp.Equal(o.Timestamp) +} + +// Container is a parsed legacy buffered container (LegacyProfilePublicBuffered +// / LegacyProfilePrivateBuffered). Streaming profiles are read with +// ParseStreamHeader instead. type Container struct { Profile Profile Metadata Metadata Payload []byte // age-encrypted ciphertext - Signature []byte // ML-DSA-65 signature (may be nil if unsigned) - // Private marks a container without a plaintext header block — - // Metadata above is zero and the real copy sits inside the encrypted - // payload (ProfilePrivateBuffered: DecodePayload after decrypting). + Signature []byte // ML-DSA-65 signature (nil if unsigned) + // Private marks a container without a plaintext header block — Metadata + // above is zero and any real copy sits inside the encrypted payload + // (DecodePayload after decrypting, for profiles that seal it). Private bool } -// Serialize writes a Container to bytes in the .icfx binary format. -// Private containers serialize with an empty header block (metaLen 0). -func (c *Container) Serialize() ([]byte, error) { - profile := c.Profile - if profile == 0 { - profile = ProfilePrivateBuffered - } - var metaJSON []byte - if !c.Private { - var err error - metaJSON, err = json.Marshal(c.Metadata) - if err != nil { - return nil, fmt.Errorf("marshaling metadata: %w", err) - } - } - - metaLen := len(metaJSON) - if metaLen > 65535 { - return nil, fmt.Errorf("metadata too large: %d bytes", metaLen) - } - - payloadLen := len(c.Payload) - if int64(payloadLen) > math.MaxUint32 { - return nil, fmt.Errorf("payload too large: %d bytes (buffered max %d; use a streaming profile)", payloadLen, uint64(math.MaxUint32)) - } - sigLen := len(c.Signature) - if sigLen > 65535 { - return nil, fmt.Errorf("signature too large: %d bytes", sigLen) - } - - totalLen := 4 + 1 + 2 + metaLen + 4 + payloadLen + 2 + sigLen - buf := make([]byte, totalLen) - - copy(buf[0:4], MagicBytes) - buf[4] = byte(profile) - binary.BigEndian.PutUint16(buf[5:7], uint16(metaLen)) - copy(buf[7:7+metaLen], metaJSON) - - offset := 7 + metaLen - binary.BigEndian.PutUint32(buf[offset:offset+4], uint32(payloadLen)) - copy(buf[offset+4:offset+4+payloadLen], c.Payload) - - offset = offset + 4 + payloadLen - binary.BigEndian.PutUint16(buf[offset:offset+2], uint16(sigLen)) - if sigLen > 0 { - copy(buf[offset+2:], c.Signature) - } - - return buf, nil -} - -// Deserialize parses .icfx binary data into a Container. +// Deserialize parses a legacy buffered container: +// magic|profile|metaLen(u16)|[meta]|payloadLen(u32)|payload|sigLen(u16)|[sig]. func Deserialize(data []byte) (*Container, error) { - if len(data) < 7 { + if len(data) < HeaderPrefixLen { return nil, ErrInvalidFormat } - if string(data[0:4]) != string(MagicBytes) { return nil, ErrInvalidMagic } - profile := Profile(data[4]) - if profile != ProfilePublicBuffered && profile != ProfilePrivateBuffered { - return nil, ErrUnsupportedVersion + if !profile.Legacy() || profile.Streaming() { + return nil, ErrUnsupportedProfile } metaLen := int(binary.BigEndian.Uint16(data[5:7])) - if len(data) < 7+metaLen+4 { + if len(data) < HeaderPrefixLen+metaLen+4 { return nil, ErrInvalidFormat } var meta Metadata private := metaLen == 0 if !private { - if err := json.Unmarshal(data[7:7+metaLen], &meta); err != nil { + if err := json.Unmarshal(data[HeaderPrefixLen:HeaderPrefixLen+metaLen], &meta); err != nil { return nil, fmt.Errorf("parsing metadata: %w", err) } } - offset := 7 + metaLen + offset := HeaderPrefixLen + metaLen // Compare in int64 so a payloadLen near math.MaxUint32 can't overflow int // (negative on 32-bit builds) and bypass the bounds check before make. payloadLen := int64(binary.BigEndian.Uint32(data[offset : offset+4])) offset += 4 - if int64(len(data)-offset) < payloadLen+2 { return nil, ErrInvalidFormat } + // Payload and signature are sub-slices of data (no second copy of a + // possibly large payload); callers own data and do not mutate it. plen := int(payloadLen) - payload := make([]byte, plen) - copy(payload, data[offset:offset+plen]) + payload := data[offset : offset+plen] offset += plen sigLen := int(binary.BigEndian.Uint16(data[offset : offset+2])) @@ -196,8 +190,7 @@ func Deserialize(data []byte) (*Container, error) { if len(data) < offset+sigLen { return nil, ErrInvalidFormat } - signature = make([]byte, sigLen) - copy(signature, data[offset:offset+sigLen]) + signature = data[offset : offset+sigLen] } return &Container{ @@ -210,8 +203,8 @@ func Deserialize(data []byte) (*Container, error) { } // EncodePayload frames metadata ahead of the file bytes for encryption: -// uint16(len(metaJSON)) || metaJSON || filedata. Every v2 payload carries -// this inner copy so the plaintext header is disposable (StripHeader). +// uint16(len(metaJSON)) || metaJSON || filedata. This sealed copy is the +// authoritative metadata; any plaintext header is disposable. func EncodePayload(meta Metadata, filedata []byte) ([]byte, error) { metaJSON, err := json.Marshal(meta) if err != nil { @@ -227,10 +220,8 @@ func EncodePayload(meta Metadata, filedata []byte) ([]byte, error) { return out, nil } -// DecodePayload splits a decrypted private-profile payload into its inner -// metadata and the original file bytes. Only valid for private profiles -// (ProfilePrivateBuffered / ProfilePrivateStreaming); public-profile payloads -// are the bare file bytes with metadata in the header. +// DecodePayload splits a decrypted payload that seals its metadata +// (Profile.SealsMetadata) into the inner metadata and the original file bytes. func DecodePayload(plaintext []byte) (Metadata, []byte, error) { if len(plaintext) < 2 { return Metadata{}, nil, ErrInvalidFormat @@ -246,8 +237,8 @@ func DecodePayload(plaintext []byte) (Metadata, []byte, error) { return meta, plaintext[2+metaLen:], nil } -// HeaderPrefixLen is the fixed-size container prefix: magic (4) + version -// (1) + header metadata length (2). +// HeaderPrefixLen is the fixed-size container prefix: magic (4) + profile (1) +// + header metadata length (2). const HeaderPrefixLen = 7 // ParseHeaderPrefix validates the fixed prefix of a serialized container and @@ -261,18 +252,16 @@ func ParseHeaderPrefix(head []byte) (profile Profile, metaLen int, err error) { return 0, 0, ErrInvalidMagic } profile = Profile(head[4]) - switch profile { - case ProfilePublicBuffered, ProfilePrivateBuffered, ProfilePrivateStreaming, ProfilePublicStreaming: - default: - return 0, 0, ErrUnsupportedVersion + if !profile.Valid() { + return 0, 0, ErrUnsupportedProfile } return profile, int(binary.BigEndian.Uint16(head[5:7])), nil } -// ZeroMetaLen rewrites the header-metadata-length field of a container prefix to -// 0, marking the container private (header block empty). Streaming callers use -// it to strip the plaintext header in place without hardcoding the field offset, -// so the byte layout stays owned by this package. Panics if head is shorter than +// ZeroMetaLen rewrites the header-metadata-length field of a container prefix +// to 0, dropping the plaintext header block. The sharing upload path uses it to +// strip the header in place without hardcoding the field offset, so the byte +// layout stays owned by this package. Panics if head is shorter than // HeaderPrefixLen (a programming error — callers read exactly that many bytes). func ZeroMetaLen(head []byte) { if len(head) < HeaderPrefixLen { @@ -283,15 +272,19 @@ func ZeroMetaLen(head []byte) { // StreamHeader locates a streaming container's payload region and trailing // signature without reading the (potentially huge) payload — the input for a -// constant-memory streaming decrypt. Applies to the streaming profiles -// (ProfilePrivateStreaming, ProfilePublicStreaming). +// constant-memory decrypt. type StreamHeader struct { - Profile Profile - Private bool - HeaderMeta Metadata // populated only when a plaintext header is present (public) - PayloadStart int64 // byte offset of the age payload within the container - PayloadLen int64 // uint64 on the wire (streaming lifts the buffered 4 GiB cap) - Signature []byte // ML-DSA-65 signature (nil when unsigned) + Profile Profile + Private bool // no plaintext header block (metaLen 0) + // HeaderMeta / HeaderRaw are the plaintext header block, present only on + // public profiles. They are NOT authenticated: anyone can rewrite them. + // Never resolve a signer or name a sender from them; the copy sealed + // inside the payload is the authority and decrypt compares the two. + HeaderMeta Metadata + HeaderRaw []byte + PayloadStart int64 // byte offset of the age payload within the container + PayloadLen int64 // uint64 on the wire + Signature []byte // ML-DSA-65 signature (nil when absent) } // ParseStreamHeader reads a streaming container's framing from r (seeking as @@ -308,7 +301,7 @@ func ParseStreamHeader(r io.ReadSeeker) (*StreamHeader, error) { return nil, err } if !profile.Streaming() { - return nil, ErrUnsupportedVersion + return nil, ErrUnsupportedProfile } sh := &StreamHeader{Profile: profile, Private: metaLen == 0} if metaLen > 0 { @@ -319,16 +312,20 @@ func ParseStreamHeader(r io.ReadSeeker) (*StreamHeader, error) { if err := json.Unmarshal(mbuf, &sh.HeaderMeta); err != nil { return nil, fmt.Errorf("parsing header metadata: %w", err) } + sh.HeaderRaw = mbuf } var plbuf [8]byte if _, err := io.ReadFull(r, plbuf[:]); err != nil { return nil, fmt.Errorf("reading payload length: %w", err) } + sh.PayloadStart = int64(HeaderPrefixLen + metaLen + 8) + // The length is attacker-controlled: reject anything that cannot fit in + // an int64 offset (plus the trailing signature-length field) rather than + // relying on the reader to refuse a wrapped-around seek. sh.PayloadLen = int64(binary.BigEndian.Uint64(plbuf[:])) - if sh.PayloadLen < 0 { + if sh.PayloadLen < 0 || sh.PayloadLen > math.MaxInt64-sh.PayloadStart-2 { return nil, ErrInvalidFormat } - sh.PayloadStart = int64(HeaderPrefixLen + metaLen + 8) // The signature length + bytes follow the payload. if _, err := r.Seek(sh.PayloadStart+sh.PayloadLen, io.SeekStart); err != nil { return nil, fmt.Errorf("seeking to signature: %w", err) @@ -346,22 +343,3 @@ func ParseStreamHeader(r io.ReadSeeker) (*StreamHeader, error) { } return sh, nil } - -// StripHeader re-serializes a BUFFERED container with an EMPTY header block — -// pure byte surgery: payload and signature are untouched, so the signature -// (which covers the payload) stays valid. It returns ErrUnsupportedVersion for -// streaming containers (Deserialize only accepts buffered profiles); the -// streaming upload path strips its header in-place instead (see -// sharing.stripStreamHeader / format.ZeroMetaLen). A stripped PUBLIC buffered -// profile has no inner metadata copy, so its signature becomes unverifiable and -// the original name is lost — acceptable for public files (server-side naming -// covers receive). Private profiles keep their authoritative inner metadata. -func StripHeader(data []byte) ([]byte, error) { - c, err := Deserialize(data) - if err != nil { - return nil, err - } - c.Private = true - c.Metadata = Metadata{} - return c.Serialize() -} diff --git a/format/icfx_v2_test.go b/format/icfx_v2_test.go deleted file mode 100644 index 0dc70b4..0000000 --- a/format/icfx_v2_test.go +++ /dev/null @@ -1,174 +0,0 @@ -package format - -import ( - "bytes" - "encoding/binary" - "encoding/json" - "strings" - "testing" - "time" -) - -func testMeta() Metadata { - return Metadata{ - SenderFingerprint: "a1b2c3d4e5f6abcd", - Timestamp: time.Now().Truncate(time.Second), - OriginalFilename: "secret-report.pdf", - IsSigned: true, - } -} - -func TestEncodeDecodePayload(t *testing.T) { - filedata := []byte("the actual file bytes") - inner, err := EncodePayload(testMeta(), filedata) - if err != nil { - t.Fatalf("EncodePayload: %v", err) - } - meta, out, err := DecodePayload(inner) - if err != nil { - t.Fatalf("DecodePayload: %v", err) - } - if meta.OriginalFilename != "secret-report.pdf" || meta.SenderFingerprint != "a1b2c3d4e5f6abcd" || !meta.IsSigned { - t.Fatalf("inner metadata round-trip: %+v", meta) - } - if !bytes.Equal(out, filedata) { - t.Fatal("file bytes round-trip mismatch") - } -} - -// TestPrivateContainer pins the privacy contract: a private (default) -// container's serialized bytes contain NO metadata plaintext. -func TestPrivateContainer(t *testing.T) { - c := &Container{ - Profile: ProfilePrivateBuffered, - Metadata: testMeta(), // ignored when Private - Payload: []byte("ciphertext"), - Signature: []byte("sig"), - Private: true, - } - data, err := c.Serialize() - if err != nil { - t.Fatalf("Serialize: %v", err) - } - if strings.Contains(string(data), "secret-report") || strings.Contains(string(data), "a1b2c3d4") { - t.Fatal("private container leaked metadata plaintext") - } - parsed, err := Deserialize(data) - if err != nil { - t.Fatalf("Deserialize: %v", err) - } - if !parsed.Private { - t.Fatal("expected Private container") - } - if parsed.Metadata.SenderFingerprint != "" { - t.Fatal("private container must deserialize with zero metadata") - } - if !bytes.Equal(parsed.Payload, c.Payload) || !bytes.Equal(parsed.Signature, c.Signature) { - t.Fatal("payload/signature round-trip mismatch") - } -} - -// TestStripHeader: payload + signature bytes survive stripping unchanged -// (the signature covers the payload, so it must stay valid), and the -// stripped bytes carry no metadata plaintext. -func TestStripHeader(t *testing.T) { - public := &Container{ - Profile: ProfilePublicBuffered, - Metadata: testMeta(), - Payload: []byte("payload-bytes-here"), - Signature: []byte("signature-bytes"), - } - data, err := public.Serialize() - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), "secret-report.pdf") { - t.Fatal("public container should carry header metadata (test premise)") - } - - stripped, err := StripHeader(data) - if err != nil { - t.Fatalf("StripHeader: %v", err) - } - if strings.Contains(string(stripped), "secret-report") { - t.Fatal("stripped container still leaks metadata") - } - parsed, err := Deserialize(stripped) - if err != nil { - t.Fatalf("Deserialize stripped: %v", err) - } - if !parsed.Private || parsed.Profile != ProfilePublicBuffered { - t.Fatalf("stripped container: private=%v profile=%#x", parsed.Private, byte(parsed.Profile)) - } - if !bytes.Equal(parsed.Payload, public.Payload) || !bytes.Equal(parsed.Signature, public.Signature) { - t.Fatal("StripHeader must leave payload and signature byte-identical") - } -} - -// TestV1Compat: hand-built v1 bytes (the pre-v2 layout with plaintext header -// metadata and a bare payload) still deserialize with header metadata. -func TestV1Compat(t *testing.T) { - metaJSON, _ := json.Marshal(testMeta()) - payload := []byte("v1-age-ciphertext") - sig := []byte("v1-sig") - - buf := make([]byte, 0, 64) - buf = append(buf, MagicBytes...) - buf = append(buf, byte(ProfilePublicBuffered)) - var l2 [2]byte - binary.BigEndian.PutUint16(l2[:], uint16(len(metaJSON))) - buf = append(buf, l2[:]...) - buf = append(buf, metaJSON...) - var l4 [4]byte - binary.BigEndian.PutUint32(l4[:], uint32(len(payload))) - buf = append(buf, l4[:]...) - buf = append(buf, payload...) - binary.BigEndian.PutUint16(l2[:], uint16(len(sig))) - buf = append(buf, l2[:]...) - buf = append(buf, sig...) - - parsed, err := Deserialize(buf) - if err != nil { - t.Fatalf("Deserialize v1: %v", err) - } - if parsed.Profile != ProfilePublicBuffered || parsed.Private { - t.Fatalf("v1 parse: profile=%#x private=%v", byte(parsed.Profile), parsed.Private) - } - if parsed.Metadata.OriginalFilename != "secret-report.pdf" { - t.Fatalf("v1 header metadata lost: %+v", parsed.Metadata) - } - if !bytes.Equal(parsed.Payload, payload) { - t.Fatal("v1 payload mismatch") - } - - // Stripping a v1 keeps its profile byte and drops the header. - stripped, err := StripHeader(buf) - if err != nil { - t.Fatalf("StripHeader v1: %v", err) - } - sp, err := Deserialize(stripped) - if err != nil { - t.Fatal(err) - } - if sp.Profile != ProfilePublicBuffered || !sp.Private || !bytes.Equal(sp.Payload, payload) { - t.Fatalf("stripped v1: profile=%#x private=%v", byte(sp.Profile), sp.Private) - } -} - -func TestParseHeaderPrefix(t *testing.T) { - c := &Container{Profile: ProfilePublicBuffered, Metadata: testMeta(), Payload: []byte("p")} - data, err := c.Serialize() - if err != nil { - t.Fatal(err) - } - profile, metaLen, err := ParseHeaderPrefix(data[:HeaderPrefixLen]) - if err != nil { - t.Fatalf("ParseHeaderPrefix: %v", err) - } - if profile != ProfilePublicBuffered || metaLen == 0 { - t.Fatalf("prefix parse: profile=%#x metaLen=%d", byte(profile), metaLen) - } - if _, _, err := ParseHeaderPrefix([]byte("BOGUS12")); err == nil { - t.Fatal("bogus magic must fail") - } -} diff --git a/format/overflow_test.go b/format/overflow_test.go deleted file mode 100644 index 27f9ce9..0000000 --- a/format/overflow_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package format - -import "testing" - -// M2 regression: Serialize must reject a buffered payload larger than the 32-bit -// on-wire length field can represent, rather than silently truncating it. -// -// Building a real >4 GiB payload would be wasteful; instead assert the guard is -// reachable by confirming a normal container serializes and documenting the -// bound. The full-size path is covered by the explicit MaxUint32 comparison in -// Serialize; here we verify round-trip integrity of the length handling for a -// representative payload so the int64 bounds math stays correct. -func TestSerializeRoundTripLength(t *testing.T) { - c := &Container{ - Profile: ProfilePrivateBuffered, - Private: true, - Payload: make([]byte, 1<<20), // 1 MiB - } - data, err := c.Serialize() - if err != nil { - t.Fatalf("serialize: %v", err) - } - got, err := Deserialize(data) - if err != nil { - t.Fatalf("deserialize: %v", err) - } - if len(got.Payload) != 1<<20 { - t.Fatalf("payload len = %d, want %d", len(got.Payload), 1<<20) - } -} - -// A truncated length prefix (claims more payload than present) must be rejected -// by the bounds check, not panic in make/copy. -func TestDeserializeRejectsTruncatedPayload(t *testing.T) { - c := &Container{Profile: ProfilePrivateBuffered, Private: true, Payload: []byte("hello")} - data, err := c.Serialize() - if err != nil { - t.Fatalf("serialize: %v", err) - } - // Chop the payload + signature tail so the declared payloadLen exceeds what - // remains. - truncated := data[:len(data)-4] - if _, err := Deserialize(truncated); err == nil { - t.Fatal("Deserialize accepted a truncated container; want error") - } -} diff --git a/hardware/chalresp/README.md b/hardware/chalresp/README.md index f3e8b04..95a144d 100644 --- a/hardware/chalresp/README.md +++ b/hardware/chalresp/README.md @@ -45,6 +45,17 @@ challenge-response — the earlier "NitroKey Pro/Storage" claim was never real). `Open`/`Challenge`/`IsSlot2Programmed` route to the transport the device was found on. PC/SC enumeration is best-effort — a missing `pcscd` never breaks HID. +## Threading + +Every hidapi call — init, enumerate, open, feature-report I/O, close — runs on +one goroutine that is locked to an OS thread for the life of the process +(`hidRun` in `chalresp_hid.go`). hidapi's macOS backend binds its +process-global `IOHIDManager` to the run loop of the thread that initialised +it and enumerates by pumping the *calling* thread's run loop, so calls from +arbitrary threads can silently see no devices; confining them to one thread +makes the run loop always the right one. The public API is unchanged; +callers need no pinning of their own. + ## Mobile (Android / iOS) is out of scope The desktop files are `//go:build !android && !ios && !nohw`; mobile uses the diff --git a/hardware/chalresp/chalresp_hid.go b/hardware/chalresp/chalresp_hid.go index 76782d8..97e562e 100644 --- a/hardware/chalresp/chalresp_hid.go +++ b/hardware/chalresp/chalresp_hid.go @@ -69,16 +69,64 @@ const ( var ( hidInitOnce sync.Once hidInitErr error - // hidMu serializes device access. A YubiKey's OTP state machine (the - // SLOT_WRITE_FLAG / RESP_PENDING flags + sequence counter) is global to the - // physical key, so two concurrent in-process operations would interleave and - // corrupt each other's frames. - hidMu sync.Mutex + // hidJobs feeds the single HID worker thread (see hidRun); hidWorkerOnce + // starts it on first use. + hidJobs chan func() + hidWorkerOnce sync.Once ) +// hidRun executes fn on the HID worker: one goroutine locked to an OS thread +// for the life of the process, through which EVERY hidapi call goes — init, +// enumerate, open, each feature report, close. +// +// hidapi's macOS backend schedules its process-global IOHIDManager on the run +// loop of the thread that initialized it, and enumeration pumps the CALLING +// thread's run loop before copying the device set. Calls made from unpinned +// goroutines land on whatever OS thread the scheduler picked, so an +// enumeration on a thread other than the initializing one services none of +// the manager's sources and comes back empty — a long-lived GUI on an Intel +// Mac reported "no hardware key" with no error for exactly this reason. +// Confining every call to one pinned thread removes the affinity problem +// outright, and keeps IOKit's mach calls clear of Go's async preemption too. +// The other backends (hidraw, HID.dll) have no thread affinity; the worker is +// harmless there. +// +// Jobs run strictly one at a time. That is also what keeps a YubiKey's OTP +// state machine (SLOT_WRITE_FLAG / RESP_PENDING + sequence counter, global to +// the physical key) consistent: a whole challenge or status read is one job, +// so two in-process operations can never interleave their frames. The cost +// is that a touch-wait (up to touchTimeout) delays a concurrent enumeration +// behind it — one device, one thread. +// +// fn must not call hidRun itself (the worker would wait on itself); the +// *Locked helpers exist for calls made from inside a job. +func hidRun(fn func() error) error { + hidWorkerOnce.Do(func() { + hidJobs = make(chan func()) + go func() { + runtime.LockOSThread() // never unlocked: this thread belongs to hidapi + for job := range hidJobs { + job() + } + }() + }) + done := make(chan error, 1) + hidJobs <- func() { + defer func() { + if r := recover(); r != nil { + done <- fmt.Errorf("chalresp: hid worker panic: %v", r) + } + }() + done <- fn() + } + return <-done +} + // hidEnsureInit initializes hidapi once for the process lifetime. hidapi is // otherwise lazily initialized; we do it explicitly for concurrency safety and // never call hid.Exit (the OS reclaims on exit), matching the old ykpers path. +// Only ever runs inside a hidRun job, so the IOHIDManager is scheduled on the +// worker thread's run loop — the one every later call pumps. func hidEnsureInit() error { hidInitOnce.Do(func() { if err := hid.Init(); err != nil { @@ -99,6 +147,18 @@ func hidEnsureInit() error { // hidList enumerates connected devices that expose the YubiKey OTP keyboard // interface. Returns an empty slice (not an error) when none are present. func hidList() ([]DeviceDescriptor, error) { + var out []DeviceDescriptor + err := hidRun(func() error { + devs, err := hidListLocked() + out = devs + return err + }) + return out, err +} + +// hidListLocked is hidList's body, for use ON the HID worker thread only +// (inside a hidRun job). +func hidListLocked() ([]DeviceDescriptor, error) { if err := hidEnsureInit(); err != nil { return nil, err } @@ -137,8 +197,6 @@ func hidList() ([]DeviceDescriptor, error) { // hidIsSlot2Programmed reads the device status report and checks CONFIG2_VALID. func hidIsSlot2Programmed(desc DeviceDescriptor) (bool, error) { - hidMu.Lock() - defer hidMu.Unlock() var programmed bool err := withDevice(desc, func(dev *hid.Device) error { st, err := readStatus(dev) @@ -154,8 +212,6 @@ func hidIsSlot2Programmed(desc DeviceDescriptor) (bool, error) { // hidChallenge runs a slot-2 HMAC-SHA1 challenge-response. mayBlock=true waits // for a physical touch on slots configured to require one. func hidChallenge(desc DeviceDescriptor, challenge []byte, mayBlock bool) ([]byte, error) { - hidMu.Lock() - defer hidMu.Unlock() var resp []byte err := withDevice(desc, func(dev *hid.Device) error { frame := buildFrame(challenge, slotChalHMAC2) @@ -176,23 +232,21 @@ func hidChallenge(desc DeviceDescriptor, challenge []byte, mayBlock bool) ([]byt } // withDevice opens the device (by its enumerated path, or the first available -// one if the descriptor carries none), runs fn, and closes it. +// one if the descriptor carries none), runs fn, and closes it — all on the HID +// worker thread, so fn's feature-report I/O never crosses threads. func withDevice(desc DeviceDescriptor, fn func(*hid.Device) error) error { - // Pin this goroutine to its OS thread for the whole device interaction. On - // macOS, IOKit's IOHIDDeviceGetReport/SetReport do mach calls that Go's async - // preemption (SIGURG, Go 1.14+) can interrupt, surfacing as a transient - // kIOReturnError (0xE00002BC) — the report backends (hidraw/HID.dll) are - // unaffected, but LockOSThread is harmless there. Keeping every open + feature - // report + close on one thread eliminates the interruption. (See go-hid #15.) - runtime.LockOSThread() - defer runtime.UnlockOSThread() + return hidRun(func() error { return withDeviceLocked(desc, fn) }) +} +// withDeviceLocked is withDevice's body, for use ON the HID worker thread only +// (inside a hidRun job). +func withDeviceLocked(desc DeviceDescriptor, fn func(*hid.Device) error) error { if err := hidEnsureInit(); err != nil { return err } path := desc.path if path == "" { - devs, err := hidList() + devs, err := hidListLocked() if err != nil { return err } diff --git a/hardware/chalresp/chalresp_worker_linux_test.go b/hardware/chalresp/chalresp_worker_linux_test.go new file mode 100644 index 0000000..869e499 --- /dev/null +++ b/hardware/chalresp/chalresp_worker_linux_test.go @@ -0,0 +1,35 @@ +//go:build linux && !android && !nohw + +package chalresp + +import ( + "syscall" + "testing" +) + +// TestHidRunPinsOneThread proves the property the macOS fix relies on: every +// job runs on the same OS thread for the life of the process, no matter which +// goroutine submitted it. +func TestHidRunPinsOneThread(t *testing.T) { + tid := func() int { + var id int + _ = hidRun(func() error { id = syscall.Gettid(); return nil }) + return id + } + first := tid() + if first == 0 { + t.Fatal("could not read the worker's thread id") + } + done := make(chan int) + for i := 0; i < 8; i++ { + go func() { done <- tid() }() + } + for i := 0; i < 8; i++ { + if got := <-done; got != first { + t.Fatalf("job ran on thread %d, want the worker thread %d", got, first) + } + } + if syscall.Gettid() == first { + t.Fatal("the worker thread must not be the test goroutine's thread") + } +} diff --git a/hardware/chalresp/chalresp_worker_test.go b/hardware/chalresp/chalresp_worker_test.go new file mode 100644 index 0000000..57e9d20 --- /dev/null +++ b/hardware/chalresp/chalresp_worker_test.go @@ -0,0 +1,65 @@ +//go:build !android && !ios && !nohw + +package chalresp + +import ( + "errors" + "sync" + "sync/atomic" + "testing" +) + +// The HID worker never touches hardware in these tests: they exercise hidRun's +// contract — every job runs on the one worker, one at a time, and nothing a job +// does can take the worker down. + +func TestHidRunReturnsJobError(t *testing.T) { + want := errors.New("job failed") + if got := hidRun(func() error { return want }); !errors.Is(got, want) { + t.Fatalf("hidRun error = %v, want %v", got, want) + } + if got := hidRun(func() error { return nil }); got != nil { + t.Fatalf("hidRun error = %v, want nil", got) + } +} + +func TestHidRunSurvivesPanic(t *testing.T) { + err := hidRun(func() error { panic("boom") }) + if err == nil || err.Error() != "chalresp: hid worker panic: boom" { + t.Fatalf("panicking job must surface as an error, got %v", err) + } + // The worker must still be alive and serving. + ran := false + if err := hidRun(func() error { ran = true; return nil }); err != nil || !ran { + t.Fatalf("worker did not serve the next job after a panic: ran=%v err=%v", ran, err) + } +} + +func TestHidRunSerializesJobs(t *testing.T) { + const n = 64 + var inJob atomic.Int32 + var overlaps atomic.Int32 + var completed atomic.Int32 + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _ = hidRun(func() error { + if inJob.Add(1) != 1 { + overlaps.Add(1) + } + completed.Add(1) + inJob.Add(-1) + return nil + }) + }() + } + wg.Wait() + if overlaps.Load() != 0 { + t.Fatalf("%d jobs overlapped; the worker must run one job at a time", overlaps.Load()) + } + if completed.Load() != n { + t.Fatalf("completed %d of %d jobs", completed.Load(), n) + } +} diff --git a/sharing/sharing.go b/sharing/sharing.go index 796ef7b..00fba01 100644 --- a/sharing/sharing.go +++ b/sharing/sharing.go @@ -68,8 +68,9 @@ type SendResult struct { // // The container header is ALWAYS stripped before upload: the stored object // must not reveal filename or sender fingerprint to the storage operator. -// v2 containers keep their encrypted inner metadata copy; a stripped v1 -// simply loses them (receive naming comes from the share record). +// Profiles that seal the metadata keep their encrypted copy; a stripped +// legacy public container simply loses it (receive naming comes from the +// share record). func SendEncrypted(ctx context.Context, c *cloud.Client, to []Recipient, ciphertextPath string, opts SendOptions) (SendResult, error) { return SendEncryptedProgress(ctx, c, to, ciphertextPath, opts, nil) } @@ -104,8 +105,9 @@ func SendEncryptedProgress(ctx context.Context, c *cloud.Client, to []Recipient, // Streaming header strip: replace the fixed prefix with a zero-length // header marker and skip the plaintext metadata block; payload and - // signature bytes stream through untouched (the signature covers only - // the payload, so it stays valid). + // signature bytes stream through untouched. The signature never covers + // the header (it binds the profile, the plaintext and the ciphertext), and + // the metadata it needs is sealed inside the payload, so it stays valid. head := make([]byte, format.HeaderPrefixLen) if _, err := io.ReadFull(f, head); err != nil { return SendResult{}, fmt.Errorf("reading container header: %w", err) diff --git a/sharing/sharing_it_test.go b/sharing/sharing_it_test.go index a52060e..04f3eb5 100644 --- a/sharing/sharing_it_test.go +++ b/sharing/sharing_it_test.go @@ -11,7 +11,9 @@ import ( "bytes" "context" "crypto/rand" + "encoding/binary" "encoding/hex" + "encoding/json" "fmt" "net/http" "os" @@ -77,10 +79,10 @@ func upgradeToBasic(t *testing.T, accountID string) { } } -// buildContainer produces a v2 ICFX container encrypted to lock, mirroring -// what the icc/ic-app encrypt flows produce locally: metadata encrypted -// inside the payload, plus a plaintext header copy (as icc --public-meta -// would) so the test can prove SendEncrypted strips it before upload. +// buildContainer produces an unsigned ProfilePublic container encrypted to +// lock, mirroring what `icc encrypt --public-meta` writes: metadata sealed +// inside the payload plus an advisory plaintext header copy, so the test can +// prove SendEncrypted strips the header before upload. func buildContainer(t *testing.T, plain []byte, lock, senderFp, origName string) []byte { t.Helper() meta := format.Metadata{ @@ -97,16 +99,30 @@ func buildContainer(t *testing.T, plain []byte, lock, senderFp, origName string) if err != nil { t.Fatalf("encrypt payload: %v", err) } - c := &format.Container{ - Profile: format.ProfilePrivateBuffered, - Metadata: meta, - Payload: payload, - } - data, err := c.Serialize() + headerMeta, err := json.Marshal(meta) if err != nil { - t.Fatalf("serialize container: %v", err) + t.Fatalf("marshal header: %v", err) + } + data := append([]byte{}, format.MagicBytes...) + data = append(data, byte(format.ProfilePublic)) + data = binary.BigEndian.AppendUint16(data, uint16(len(headerMeta))) + data = append(data, headerMeta...) + data = binary.BigEndian.AppendUint64(data, uint64(len(payload))) + data = append(data, payload...) + return binary.BigEndian.AppendUint16(data, 0) +} + +// stripHeader returns the container with its advisory header removed, as the +// upload path does in place (format.ZeroMetaLen + skipping the header bytes). +func stripHeader(t *testing.T, data []byte) []byte { + t.Helper() + head := append([]byte{}, data[:format.HeaderPrefixLen]...) + _, metaLen, err := format.ParseHeaderPrefix(head) + if err != nil { + t.Fatalf("parse prefix: %v", err) } - return data + format.ZeroMetaLen(head) + return append(head, data[format.HeaderPrefixLen+metaLen:]...) } func TestShareContainerRoundTrip(t *testing.T) { @@ -153,10 +169,7 @@ func TestShareContainerRoundTrip(t *testing.T) { } // The uploaded object is the STRIPPED container: same payload, empty // header (the plaintext metadata block is removed before upload). - strippedLocal, err := format.StripHeader(container) - if err != nil { - t.Fatalf("strip reference: %v", err) - } + strippedLocal := stripHeader(t, container) if sent.ShareID == "" || sent.CiphertextBytes != int64(len(strippedLocal)) { t.Fatalf("send result mismatch: %+v (stripped container %d bytes)", sent, len(strippedLocal)) } @@ -202,14 +215,15 @@ func TestShareContainerRoundTrip(t *testing.T) { if format.Detect(buf.Bytes()) != format.FormatICFX { t.Fatal("downloaded payload is not an ICFX container") } - parsed, err := format.Deserialize(buf.Bytes()) + sh, err := format.ParseStreamHeader(bytes.NewReader(buf.Bytes())) if err != nil { - t.Fatalf("deserialize: %v", err) + t.Fatalf("parse stored container: %v", err) } - if !parsed.Private || parsed.Metadata.OriginalFilename != "" { - t.Fatalf("stored container should be private: %+v", parsed.Metadata) + if !sh.Private || sh.HeaderMeta.OriginalFilename != "" { + t.Fatalf("stored container should carry no header: %+v", sh.HeaderMeta) } - inner, err := crypto.Decrypt(parsed.Payload, kpBob.EncryptionIdentity) + storedPayload := buf.Bytes()[sh.PayloadStart : sh.PayloadStart+sh.PayloadLen] + inner, err := crypto.Decrypt(storedPayload, kpBob.EncryptionIdentity) if err != nil { t.Fatalf("decrypt payload: %v", err) } @@ -233,7 +247,7 @@ func TestShareContainerRoundTrip(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := crypto.Decrypt(parsed.Payload, kpEve.EncryptionIdentity); err == nil { + if _, err := crypto.Decrypt(storedPayload, kpEve.EncryptionIdentity); err == nil { t.Fatal("decrypt with wrong key should fail") } diff --git a/validate/file.go b/validate/file.go index dfcb80a..7d4fa3b 100644 --- a/validate/file.go +++ b/validate/file.go @@ -1,13 +1,14 @@ package validate import ( + "bytes" "fmt" "github.com/instacryptio/icfx/format" ) -// ValidateICFXFile checks that data is a valid encrypted file (ICFX, Age, or Armored format). -// This performs structural validation only — it does NOT decrypt the data. +// ValidateICFXFile checks that data is a structurally valid encrypted file +// (ICFX, Age, or Armored format). It does NOT decrypt the data. func ValidateICFXFile(data []byte) error { if len(data) == 0 { return fmt.Errorf("file is empty") @@ -16,7 +17,7 @@ func ValidateICFXFile(data []byte) error { detected := format.Detect(data) switch detected { case format.FormatICFX: - if _, err := format.Deserialize(data); err != nil { + if err := validateContainer(data); err != nil { return fmt.Errorf("invalid ICFX file: %w", err) } return nil @@ -32,3 +33,21 @@ func ValidateICFXFile(data []byte) error { return fmt.Errorf("unrecognized file format: not an ICFX, Age, or armored file") } } + +// validateContainer parses the container framing for whichever layout the +// profile byte selects. +func validateContainer(data []byte) error { + if len(data) < format.HeaderPrefixLen { + return format.ErrInvalidFormat + } + profile, _, err := format.ParseHeaderPrefix(data[:format.HeaderPrefixLen]) + if err != nil { + return err + } + if !profile.Streaming() { + _, err := format.Deserialize(data) + return err + } + _, err = format.ParseStreamHeader(bytes.NewReader(data)) + return err +} diff --git a/validate/file_test.go b/validate/file_test.go index 2a4dc52..754a1ec 100644 --- a/validate/file_test.go +++ b/validate/file_test.go @@ -1,14 +1,26 @@ package validate import ( + "encoding/binary" "testing" "github.com/instacryptio/icfx/crypto" "github.com/instacryptio/icfx/format" ) +// container hand-builds an unsigned ProfilePrivate container around an age +// payload (the encrypt package cannot be imported here without a cycle). +func container(t *testing.T, payload []byte) []byte { + t.Helper() + buf := append([]byte{}, format.MagicBytes...) + buf = append(buf, byte(format.ProfilePrivate)) + buf = binary.BigEndian.AppendUint16(buf, 0) + buf = binary.BigEndian.AppendUint64(buf, uint64(len(payload))) + buf = append(buf, payload...) + return binary.BigEndian.AppendUint16(buf, 0) +} + func TestValidateICFXFile(t *testing.T) { - // Generate a real encrypted file for valid test kp, err := crypto.GenerateKeyPair() if err != nil { t.Fatalf("generating key pair: %v", err) @@ -17,20 +29,23 @@ func TestValidateICFXFile(t *testing.T) { if err != nil { t.Fatalf("encrypting: %v", err) } - - // Create an armored file armored := format.ArmorEncode([]byte("some payload"), format.ArmorLockLabel) + whole := container(t, encrypted) + truncated := whole[:len(whole)-3] // signature-length field cut off tests := []struct { name string data []byte wantErr bool }{ + {"valid container", whole, false}, {"valid age encrypted", encrypted, false}, {"valid armored", armored, false}, {"empty", nil, true}, {"random bytes", []byte("this is not encrypted at all"), true}, {"truncated ICFX magic", []byte("ICFX"), true}, + {"unknown profile", []byte("ICFX\x09\x00\x00rest"), true}, + {"truncated container", truncated, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {