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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ All notable changes to Cipherleaf will be documented here.

The project follows semantic versioning after the first public release.

## 1.0.1 - 2026-07-17

### Fixed

- Accept native post-quantum hybrid age recipients and multi-identity files.
- Treat dotenv keys as flat names, including literal dots, and reject SOPS
metadata paths before mutation.
- Safely handle SOPS path-syntax edge cases and keep unaddressable values
read-only.
- Preserve array semantics when elements are removed and when arrays become
empty.
- Clear stale save review and validation state after undo or redo.
- Derive package checks from project versions and verify universal release
binaries.

## 1.0.0 - 2026-07-17

### Added
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ values in JSON-compatible document trees: strings, numbers, Booleans, and
nulls. It does not create identities, create a new encrypted document, rotate
recipients, or replace SOPS policy management.

Native age recipients include classic X25519 (`age1…`) and post-quantum hybrid
(`age1pq1…`) identities when the installed age and SOPS versions support them.
Plugin recipients are intentionally outside the current scope.

## Workspace

![Cipherleaf workspace with a synthetic SOPS document](docs/images/cipherleaf-workspace.png)
Expand Down Expand Up @@ -80,6 +84,11 @@ Run the `Cipherleaf` scheme from Xcode.
4. Add, edit, rename, or remove scalar values.
5. Save and review the redacted list of changed paths.

YAML and JSON use dotted paths for nested object keys. Dotenv documents are
flat: a dot in `SERVICE.TOKEN` is part of that single key, not a nesting
separator. Cipherleaf rejects paths reserved for SOPS metadata before an edit
can reach the save step.

Cipherleaf shows the nearest `.sops.yaml` when one exists, but saving does not
require it. Existing SOPS metadata remains authoritative, and Cipherleaf
refuses a save if the recipient set changes unexpectedly.
Expand Down
23 changes: 20 additions & 3 deletions Scripts/package.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DERIVED_DATA="$ROOT/.build/ReleaseDerivedData"
OUTPUT_DIRECTORY="$ROOT/.build/release"
VERSION="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo local)}"
PROJECT_VERSION="$(
awk '/^[[:space:]]+MARKETING_VERSION:/ { gsub(/"/, "", $2); print $2; exit }' \
"$ROOT/project.yml"
)"
PROJECT_BUILD="$(
awk '/^[[:space:]]+CURRENT_PROJECT_VERSION:/ { gsub(/"/, "", $2); print $2; exit }' \
"$ROOT/project.yml"
)"
VERSION="${VERSION:-$PROJECT_VERSION}"

cd "$ROOT"

Expand All @@ -30,13 +38,22 @@ xcodebuild build \

APP="$DERIVED_DATA/Build/Products/Release/Cipherleaf.app"
ARCHIVE="$OUTPUT_DIRECTORY/Cipherleaf-$VERSION-unsigned.zip"
BINARY="$APP/Contents/MacOS/Cipherleaf"
ARCHITECTURES="$(lipo -archs "$BINARY")"

for architecture in arm64 x86_64; do
if [[ " $ARCHITECTURES " != *" $architecture "* ]]; then
echo "Release binary is missing the $architecture architecture" >&2
exit 1
fi
done

test -f "$APP/Contents/Resources/Cipherleaf.icns"
test -f "$APP/Contents/Resources/Assets.car"
test -f "$APP/Contents/Resources/PrivacyInfo.xcprivacy"
test "$(plutil -extract CFBundleIconName raw "$APP/Contents/Info.plist")" = "Cipherleaf"
test "$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist")" = "1.0.0"
test "$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist")" = "1"
test "$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist")" = "$PROJECT_VERSION"
test "$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist")" = "$PROJECT_BUILD"
test "$(plutil -extract LSMinimumSystemVersion raw "$APP/Contents/Info.plist")" = "15.0"

rm -f "$ARCHIVE"
Expand Down
13 changes: 10 additions & 3 deletions Scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ if [[ -z "$VERSION" || -z "$TEAM_ID" ]]; then
exit 1
fi

if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9._-]+)?$ ]]; then
echo "VERSION must be a release version such as 1.0.0 or 1.0.0-rc.1" >&2
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "VERSION must contain three dot-separated integers such as 1.0.0" >&2
exit 1
fi

Expand Down Expand Up @@ -62,7 +62,7 @@ if [[ "${ALLOW_UNTAGGED:-0}" != "1" ]] && \
exit 1
fi

for tool in awk codesign ditto git grep plutil shasum sleep spctl unzip xcodebuild xcodegen xcrun; do
for tool in awk codesign ditto git grep lipo plutil shasum sleep spctl unzip xcodebuild xcodegen xcrun; do
if ! command -v "$tool" >/dev/null; then
echo "Required tool is unavailable: $tool" >&2
exit 1
Expand Down Expand Up @@ -111,6 +111,13 @@ xcodebuild -exportArchive \
-allowProvisioningUpdates

SIGNED_APP="$SIGNED_EXPORT/Cipherleaf.app"
ARCHITECTURES="$(lipo -archs "$SIGNED_APP/Contents/MacOS/Cipherleaf")"
for architecture in arm64 x86_64; do
if [[ " $ARCHITECTURES " != *" $architecture "* ]]; then
echo "Release binary is missing the $architecture architecture" >&2
exit 1
fi
done
codesign --verify --deep --strict --verbose=2 "$SIGNED_APP"

xcodebuild -exportArchive \
Expand Down
28 changes: 18 additions & 10 deletions Sources/Cipherleaf/Domains/Secrets/AddSecretSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,23 @@ struct AddSecretSheet: View {
VStack(spacing: 0) {
Form {
Section("Location") {
TextField("Path", text: $path, prompt: Text("database.password"))
.font(.body.monospaced())
.focused($focusedField, equals: .path)
.onSubmit {
focusedField = .value
}
Text("Use dots for nested object keys.")
.font(.caption)
.foregroundStyle(.secondary)
TextField(
"Path",
text: $path,
prompt: Text(secrets.usesFlatKeys ? "DATABASE_PASSWORD" : "database.password")
)
.font(.body.monospaced())
.focused($focusedField, equals: .path)
.onSubmit {
focusedField = .value
}
Text(
secrets.usesFlatKeys
? "dotenv values use one flat key; dots are part of the key."
: "Use dots for nested object keys."
)
.font(.caption)
.foregroundStyle(.secondary)
}

Section("Value") {
Expand Down Expand Up @@ -188,7 +196,7 @@ struct AddSecretSheet: View {
}

private var isValid: Bool {
guard (try? SecretPath.parseEditablePath(path)) != nil else {
guard secrets.isValidNewPath(path) else {
return false
}
return kind != .number || SecretValue.validateNumber(textValue)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ struct RenameSecretSheet: View {
}

private var isValid: Bool {
(try? SecretPath.parseEditablePath(key))?.components.count == 1
secrets.isValidRenameKey(key, at: path)
}

private func rename() {
Expand Down
27 changes: 25 additions & 2 deletions Sources/Cipherleaf/Domains/Secrets/SecretDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,19 @@ private struct SecretEditor: View {

var body: some View {
Form {
SecretIdentitySection(path: path, value: value)
SecretIdentitySection(
path: path,
value: value,
isEditable: isEditable
)

Section("Value") {
Picker("Type", selection: kindBinding) {
ForEach(SecretScalarKind.allCases) { kind in
Text(kind.title).tag(kind)
}
}
.disabled(!isEditable)

editor
}
Expand All @@ -71,13 +76,14 @@ private struct SecretEditor: View {
Button("Rename key…") {
secrets.presentRename(at: path)
}
.disabled(path.editablePath == nil)
.disabled(!secrets.canRename(at: path))

Spacer()

Button("Remove value", role: .destructive) {
requestRemoval()
}
.disabled(!isEditable)
}
} footer: {
Text("Change summaries contain paths only. Values are never included.")
Expand Down Expand Up @@ -137,10 +143,12 @@ private struct SecretEditor: View {
TextField("Value", text: $textDraft)
.textFieldStyle(.roundedBorder)
.focused($focusedField, equals: .content)
.disabled(!isEditable)
} else {
SecureField("Value", text: $textDraft)
.textFieldStyle(.roundedBorder)
.focused($focusedField, equals: .content)
.disabled(!isEditable)
}

visibilityButton
Expand All @@ -165,6 +173,7 @@ private struct SecretEditor: View {
.textFieldStyle(.roundedBorder)
.font(.body.monospaced())
.focused($focusedField, equals: .content)
.disabled(!isEditable)
.onSubmit {
commitDraftIfValid()
}
Expand All @@ -188,6 +197,7 @@ private struct SecretEditor: View {
HStack {
if isRevealed {
Toggle("Enabled", isOn: booleanBinding)
.disabled(!isEditable)
} else {
Text("••••••••")
.font(.body.monospaced())
Expand Down Expand Up @@ -221,6 +231,10 @@ private struct SecretEditor: View {
)
}

private var isEditable: Bool {
secrets.canEdit(at: path)
}

private var booleanBinding: Binding<Bool> {
Binding(
get: {
Expand Down Expand Up @@ -309,6 +323,7 @@ private struct SecretEditor: View {
private struct SecretIdentitySection: View {
let path: SecretPath
let value: SecretValue
let isEditable: Bool

var body: some View {
Section("Identity") {
Expand All @@ -318,6 +333,14 @@ private struct SecretIdentitySection: View {
.textSelection(.disabled)
}
LabeledContent("Stored type", value: value.kindName)
if !isEditable {
Label(
"This path is read-only because sops set and unset cannot address its key syntax safely.",
systemImage: "exclamationmark.triangle.fill"
)
.font(.caption)
.foregroundStyle(.orange)
}
}
}
}
47 changes: 46 additions & 1 deletion Sources/Cipherleaf/Domains/Secrets/SecretsFacade.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ final class SecretsFacade {
session.sourceContainsComments
}

var usesFlatKeys: Bool {
session.format == .dotenv
}

var isOpen: Bool {
session.phase == .open
}
Expand Down Expand Up @@ -107,13 +111,29 @@ final class SecretsFacade {
session.value(at: path)
}

func isValidNewPath(_ rawPath: String) -> Bool {
(try? session.pathForNewValue(rawPath)) != nil
}

func canRename(at path: SecretPath) -> Bool {
session.canRename(at: path)
}

func canEdit(at path: SecretPath) -> Bool {
session.canEdit(at: path)
}

func isValidRenameKey(_ rawValue: String, at path: SecretPath) -> Bool {
session.isValidRenameKey(rawValue, at: path)
}

func changeKind(at path: SecretPath) -> DocumentChangeKind? {
session.changeKind(at: path)
}

func add(path rawPath: String, value: SecretValue) -> Bool {
do {
let path = try SecretPath.parseEditablePath(rawPath)
let path = try session.pathForNewValue(rawPath)
try session.add(value, at: path)
selectedPath = path
notices.statusMessage = nil
Expand Down Expand Up @@ -180,12 +200,18 @@ final class SecretsFacade {
}

func undo() {
let invalidBaselines = invalidValueBaselines()
session.undo()
repairValidationIssues(preserving: invalidBaselines)
repairPresentedSheet()
repairSelection()
}

func redo() {
let invalidBaselines = invalidValueBaselines()
session.redo()
repairValidationIssues(preserving: invalidBaselines)
repairPresentedSheet()
repairSelection()
}

Expand Down Expand Up @@ -253,6 +279,25 @@ final class SecretsFacade {
}
}

private func invalidValueBaselines() -> [SecretPath: SecretValue] {
Dictionary(
uniqueKeysWithValues: validationIssues.keys.compactMap { path in
session.value(at: path).map { (path, $0) }
}
)
}

private func repairValidationIssues(
preserving baselines: [SecretPath: SecretValue]
) {
validationIssues = validationIssues.filter { path, _ in
guard case .number? = session.value(at: path) else {
return false
}
return session.value(at: path) == baselines[path]
}
}

private func repairSelection() {
if let selectedPath, session.value(at: selectedPath) != nil {
return
Expand Down
Loading