@hasna/recordings 0.2.11 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +349 -69
  2. package/bun.lock +3 -0
  3. package/dist/__tests__/helpers/native-fs-guard.d.ts +2 -0
  4. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -0
  5. package/dist/cli/index.js +1728 -320
  6. package/dist/cli/macos-permissions.d.ts +13 -0
  7. package/dist/cli/macos-permissions.d.ts.map +1 -0
  8. package/dist/cli/options.d.ts +12 -0
  9. package/dist/cli/options.d.ts.map +1 -1
  10. package/dist/db/pg-migrations.d.ts.map +1 -1
  11. package/dist/db/recordings.d.ts +2 -1
  12. package/dist/db/recordings.d.ts.map +1 -1
  13. package/dist/db/remote-storage.d.ts +5 -1
  14. package/dist/db/remote-storage.d.ts.map +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +628 -147
  18. package/dist/lib/bun-runtime.d.ts +11 -0
  19. package/dist/lib/bun-runtime.d.ts.map +1 -0
  20. package/dist/lib/config.d.ts +8 -1
  21. package/dist/lib/config.d.ts.map +1 -1
  22. package/dist/lib/enhancer.d.ts +10 -2
  23. package/dist/lib/enhancer.d.ts.map +1 -1
  24. package/dist/lib/install-maintenance.d.ts +24 -0
  25. package/dist/lib/install-maintenance.d.ts.map +1 -0
  26. package/dist/lib/machine.d.ts +2 -0
  27. package/dist/lib/machine.d.ts.map +1 -0
  28. package/dist/lib/recorder.d.ts +2 -1
  29. package/dist/lib/recorder.d.ts.map +1 -1
  30. package/dist/lib/recording-create-identity.d.ts +13 -0
  31. package/dist/lib/recording-create-identity.d.ts.map +1 -0
  32. package/dist/lib/release-install-policy.d.ts +31 -0
  33. package/dist/lib/release-install-policy.d.ts.map +1 -0
  34. package/dist/lib/transcriber.d.ts.map +1 -1
  35. package/dist/mcp/index.d.ts.map +1 -1
  36. package/dist/mcp/index.js +977 -194
  37. package/dist/sdk/index.js +5 -1
  38. package/dist/sdk/v1.generated.d.ts +4 -0
  39. package/dist/sdk/v1.generated.d.ts.map +1 -1
  40. package/dist/server/cloud-config.d.ts +9 -0
  41. package/dist/server/cloud-config.d.ts.map +1 -0
  42. package/dist/server/cloud-readiness.d.ts +10 -0
  43. package/dist/server/cloud-readiness.d.ts.map +1 -0
  44. package/dist/server/cloud.d.ts +9 -18
  45. package/dist/server/cloud.d.ts.map +1 -1
  46. package/dist/server/index.js +1654 -304
  47. package/dist/server/migrate-command.d.ts +11 -0
  48. package/dist/server/migrate-command.d.ts.map +1 -0
  49. package/dist/server/openapi.d.ts +22 -0
  50. package/dist/server/openapi.d.ts.map +1 -1
  51. package/dist/server/repo.d.ts +8 -1
  52. package/dist/server/repo.d.ts.map +1 -1
  53. package/dist/server/serve.d.ts +6 -1
  54. package/dist/server/serve.d.ts.map +1 -1
  55. package/dist/server/v1.d.ts.map +1 -1
  56. package/dist/storage.js +515 -80
  57. package/dist/store.d.ts +3 -1
  58. package/dist/store.d.ts.map +1 -1
  59. package/dist/types/index.d.ts +10 -0
  60. package/dist/types/index.d.ts.map +1 -1
  61. package/dist/version.d.ts +1 -1
  62. package/package.json +7 -4
  63. package/packaging/macos/Empty.entitlements +5 -0
  64. package/packaging/macos/Library/LaunchDaemons/com.hasna.recordings.updater.plist +34 -0
  65. package/packaging/macos/Verifier.entitlements +10 -0
  66. package/packaging/macos/artifact-verifier.sb +33 -0
  67. package/packaging/macos/build_release_pkg.sh +872 -0
  68. package/packaging/macos/managed_bootstrap.sh +623 -0
  69. package/packaging/macos/pkgutil_fingerprint.awk +37 -0
  70. package/packaging/macos/release_lifecycle.ts +463 -0
  71. package/packaging/macos/scripts/postinstall +565 -0
  72. package/packaging/macos/scripts/preinstall +297 -0
  73. package/scripts/build_companion_cli.sh +337 -0
  74. package/scripts/build_native_fs_guard.sh +59 -0
  75. package/scripts/generate-sdk.ts +19 -1
  76. package/scripts/install_macos_app.sh +1919 -98
  77. package/scripts/macos_artifact.ts +5316 -0
  78. package/scripts/migrate.ts +38 -10
  79. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  80. package/scripts/native/recordings_fs_guard.c +1167 -0
  81. package/scripts/native_fs_guard.ts +158 -0
  82. package/scripts/release-guard.ts +20 -1
  83. package/scripts/resolve_tailscale_cli.sh +389 -0
  84. package/scripts/smoke_macos_app.sh +573 -0
  85. package/src/native/Recordings/App/ContentView.swift +83 -0
  86. package/src/native/Recordings/App/MenuBarStatusView.swift +102 -0
  87. package/src/native/Recordings/App/RecordWorkspaceView.swift +468 -0
  88. package/src/native/Recordings/App/RecordingDetailView.swift +138 -0
  89. package/src/native/Recordings/App/RecordingsApp.swift +367 -44
  90. package/src/native/Recordings/App/RecordingsListView.swift +130 -0
  91. package/src/native/Recordings/App/RecordingsStore.swift +206 -0
  92. package/src/native/Recordings/App/RuntimeSmoke.swift +158 -0
  93. package/src/native/Recordings/App/SidebarView.swift +212 -0
  94. package/src/native/Recordings/App/Theme.swift +87 -0
  95. package/src/native/Recordings/Package.resolved +5 -5
  96. package/src/native/Recordings/Package.swift +62 -2
  97. package/src/native/Recordings/RecordingsLib/AccessibilityPromptGate.swift +104 -0
  98. package/src/native/Recordings/RecordingsLib/BrandAssets.swift +0 -32
  99. package/src/native/Recordings/RecordingsLib/ChromeSurface.swift +17 -0
  100. package/src/native/Recordings/RecordingsLib/Info.plist +7 -3
  101. package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +37 -0
  102. package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +32 -2
  103. package/src/native/Recordings/RecordingsLib/NativeMachineIdentity.swift +24 -0
  104. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +262 -23
  105. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +56 -0
  106. package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +56 -0
  107. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +321 -25
  108. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +605 -90
  109. package/src/native/Recordings/RecordingsLib/Recording.swift +141 -0
  110. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +3704 -376
  111. package/src/native/Recordings/RecordingsLib/RecordingStartControlPresentation.swift +29 -0
  112. package/src/native/Recordings/RecordingsLib/RecordingsCLI.entitlements +10 -0
  113. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +222 -0
  114. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -11
  115. package/src/native/Recordings/RecordingsLib/SpeechIntent.swift +331 -0
  116. package/src/native/Recordings/RecordingsLib/SpeechIntentClassifier.swift +232 -0
  117. package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +17 -7
  118. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +1343 -0
  119. package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +98 -0
  120. package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +20 -0
  121. package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +370 -1
  122. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +987 -1
  123. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +385 -0
  124. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +835 -8
  125. package/src/native/Recordings/RecordingsTests/RecordingBridgeTests.swift +180 -0
  126. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +760 -0
  127. package/src/native/Recordings/RecordingsTests/RecordingStartControlPresentationTests.swift +42 -0
  128. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +264 -0
  129. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +129 -0
  130. package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +600 -0
  131. package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +62 -1
  132. package/src/native/Recordings/RecordingsTests/TranscriptionResultIdentityTests.swift +21 -0
  133. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +739 -0
  134. package/src/native/Recordings/Updater/Broker/ActivationRecoveryPolicy.swift +254 -0
  135. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +463 -0
  136. package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +55 -0
  137. package/src/native/Recordings/Updater/Broker/ArtifactIngest.swift +341 -0
  138. package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +477 -0
  139. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +624 -0
  140. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +63 -0
  141. package/src/native/Recordings/Updater/Broker/CodeValidation.swift +561 -0
  142. package/src/native/Recordings/Updater/Broker/DarwinACLValidator.swift +66 -0
  143. package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +50 -0
  144. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +362 -0
  145. package/src/native/Recordings/Updater/Broker/InstallRecovery.swift +662 -0
  146. package/src/native/Recordings/Updater/Broker/MonotonicState.swift +456 -0
  147. package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +274 -0
  148. package/src/native/Recordings/Updater/Broker/VerifierRunner.swift +81 -0
  149. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +259 -0
  150. package/src/native/Recordings/Updater/BrokerTests/CanonicalReleaseOrderTests.swift +21 -0
  151. package/src/native/Recordings/Updater/Client/ClientMain.swift +119 -0
  152. package/src/native/Recordings/Updater/Protocol/BoundedProcess.swift +159 -0
  153. package/src/native/Recordings/Updater/Protocol/CandidateMetadataPolicy.swift +214 -0
  154. package/src/native/Recordings/Updater/Protocol/HostOSVersionPolicy.swift +55 -0
  155. package/src/native/Recordings/Updater/Protocol/MonotonicReleasePolicy.swift +152 -0
  156. package/src/native/Recordings/Updater/Protocol/ReleaseEnvelope.swift +229 -0
  157. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +119 -0
  158. package/src/native/Recordings/Updater/ProtocolTests/BoundedProcessRunnerTests.swift +60 -0
  159. package/src/native/Recordings/Updater/ProtocolTests/CandidateMetadataPolicyTests.swift +168 -0
  160. package/src/native/Recordings/Updater/ProtocolTests/HostOSVersionPolicyTests.swift +62 -0
  161. package/src/native/Recordings/Updater/ProtocolTests/MonotonicReleasePolicyTests.swift +172 -0
  162. package/src/native/Recordings/Updater/ProtocolTests/ReleaseEnvelopeValidationTests.swift +65 -0
  163. package/src/native/Recordings/Updater/Signer/SignerMain.swift +210 -0
  164. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +725 -0
  165. package/src/native/Recordings/Updater/VerifierLauncher/include/RecordingsVerifierLauncher.h +33 -0
  166. package/src/native/Recordings/build.sh +1973 -236
  167. package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +0 -380
@@ -0,0 +1,172 @@
1
+ import Foundation
2
+ import Testing
3
+ @testable import RecordingsUpdateProtocol
4
+
5
+ struct MonotonicReleasePolicyTests {
6
+ private let epoch: UInt64 = 7
7
+ private let cohort = String(repeating: "a", count: 64)
8
+ private let digest = String(repeating: "b", count: 64)
9
+ private let releaseID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
10
+
11
+ @Test("an absent state admits only the bootstrap release")
12
+ func absentStateRequiresBootstrap() throws {
13
+ #expect(try assess(candidate()) == .advance)
14
+ #expect(throws: MonotonicReleasePolicyError.bootstrapRequired) {
15
+ try assess(candidate(purpose: "update"))
16
+ }
17
+ }
18
+
19
+ @Test("an exact seen bootstrap resumes without advancing")
20
+ func exactSeenBootstrapResumes() throws {
21
+ let bootstrap = candidate()
22
+ #expect(try assess(bootstrap, current: state(from: bootstrap, phase: .seen)) == .resumeSeen)
23
+ }
24
+
25
+ @Test("an exact committed bootstrap is idempotent")
26
+ func exactCommittedBootstrapIsIdempotent() throws {
27
+ let bootstrap = candidate()
28
+ #expect(
29
+ try assess(bootstrap, current: state(from: bootstrap, phase: .committed))
30
+ == .alreadyCommitted
31
+ )
32
+ }
33
+
34
+ @Test("a higher update cannot supersede a seen release")
35
+ func higherUpdateIsBlockedWhileSeen() {
36
+ let pending = candidate(purpose: "update", sequence: 2)
37
+ let next = candidate(purpose: "update", sequence: 3, releaseID: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", digest: String(repeating: "c", count: 64))
38
+ #expect(throws: MonotonicReleasePolicyError.pendingSeenRecoveryRequired) {
39
+ try assess(next, current: state(from: pending, phase: .seen))
40
+ }
41
+ }
42
+
43
+ @Test("a higher update advances after the current release commits")
44
+ func higherUpdateAdvancesAfterCommit() throws {
45
+ let committed = candidate(purpose: "update", sequence: 2)
46
+ let next = candidate(purpose: "update", sequence: 3, releaseID: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", digest: String(repeating: "c", count: 64))
47
+ #expect(try assess(next, current: state(from: committed, phase: .committed)) == .advance)
48
+ }
49
+
50
+ @Test("a rolled-back seen release admits only a strictly newer cohort-compatible update")
51
+ func abortedReleasePreservesMonotonicBarrier() throws {
52
+ let abortedCandidate = candidate(purpose: "update", sequence: 2)
53
+ let aborted = state(from: abortedCandidate, phase: .aborted)
54
+ let next = candidate(
55
+ purpose: "update",
56
+ sequence: 3,
57
+ releaseID: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff",
58
+ digest: String(repeating: "c", count: 64)
59
+ )
60
+ #expect(try assess(next, current: aborted) == .advance)
61
+ #expect(throws: MonotonicReleasePolicyError.sequenceRollbackOrConflict) {
62
+ try assess(abortedCandidate, current: aborted)
63
+ }
64
+ #expect(throws: MonotonicReleasePolicyError.immutableCohortChange) {
65
+ try assess(
66
+ candidate(
67
+ purpose: "update",
68
+ sequence: 3,
69
+ cohort: String(repeating: "c", count: 64)
70
+ ),
71
+ current: aborted
72
+ )
73
+ }
74
+ }
75
+
76
+ @Test("an exact committed update is idempotent")
77
+ func exactCommittedUpdateIsIdempotent() throws {
78
+ let update = candidate(purpose: "update", sequence: 2)
79
+ #expect(
80
+ try assess(update, current: state(from: update, phase: .committed))
81
+ == .alreadyCommitted
82
+ )
83
+ }
84
+
85
+ @Test("seen state rejects wrong cohort, key, identity, and digest")
86
+ func seenStateRejectsEveryNonExactReplay() {
87
+ let pending = candidate(purpose: "update", sequence: 2)
88
+ let seen = state(from: pending, phase: .seen)
89
+ let conflicts = [
90
+ candidate(purpose: "update", sequence: 2, cohort: String(repeating: "c", count: 64)),
91
+ candidate(purpose: "update", keyEpoch: epoch + 1, sequence: 2),
92
+ candidate(purpose: "update", sequence: 2, releaseID: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff"),
93
+ candidate(purpose: "update", sequence: 2, digest: String(repeating: "c", count: 64)),
94
+ ]
95
+
96
+ for conflict in conflicts {
97
+ let expected: MonotonicReleasePolicyError = conflict.keyEpoch == epoch
98
+ ? .pendingSeenRecoveryRequired
99
+ : .immutableCohortChange
100
+ #expect(throws: expected) {
101
+ try assess(conflict, current: seen)
102
+ }
103
+ }
104
+ }
105
+
106
+ @Test("committed state rejects cohort, key, and same-sequence digest conflicts")
107
+ func committedStateRejectsBindingConflicts() {
108
+ let committedCandidate = candidate(purpose: "update", sequence: 2)
109
+ let committed = state(from: committedCandidate, phase: .committed)
110
+
111
+ #expect(throws: MonotonicReleasePolicyError.immutableCohortChange) {
112
+ try assess(
113
+ candidate(purpose: "update", sequence: 3, cohort: String(repeating: "c", count: 64)),
114
+ current: committed
115
+ )
116
+ }
117
+ #expect(throws: MonotonicReleasePolicyError.immutableCohortChange) {
118
+ try assess(candidate(purpose: "update", keyEpoch: epoch + 1, sequence: 3), current: committed)
119
+ }
120
+ #expect(throws: MonotonicReleasePolicyError.sequenceRollbackOrConflict) {
121
+ try assess(
122
+ candidate(purpose: "update", sequence: 2, digest: String(repeating: "c", count: 64)),
123
+ current: committed
124
+ )
125
+ }
126
+ }
127
+
128
+ private func assess(
129
+ _ value: MonotonicReleaseCandidate,
130
+ current: MonotonicReleaseStateSnapshot? = nil
131
+ ) throws -> MonotonicReleasePolicyDecision {
132
+ try MonotonicReleasePolicy.assess(
133
+ candidate: value,
134
+ current: current,
135
+ initialKeyEpoch: epoch,
136
+ allowedKeyEpochs: [epoch]
137
+ )
138
+ }
139
+
140
+ private func candidate(
141
+ purpose: String = "bootstrap",
142
+ keyEpoch: UInt64? = nil,
143
+ sequence: UInt64 = 1,
144
+ releaseID: String? = nil,
145
+ cohort: String? = nil,
146
+ digest: String? = nil
147
+ ) -> MonotonicReleaseCandidate {
148
+ MonotonicReleaseCandidate(
149
+ purpose: purpose,
150
+ keyEpoch: keyEpoch ?? epoch,
151
+ releaseSequence: sequence,
152
+ releaseID: releaseID ?? self.releaseID,
153
+ cohortPackageSHA256: cohort ?? self.cohort,
154
+ envelopePayloadSHA256: digest ?? self.digest
155
+ )
156
+ }
157
+
158
+ private func state(
159
+ from candidate: MonotonicReleaseCandidate,
160
+ phase: MonotonicReleasePhase
161
+ ) -> MonotonicReleaseStateSnapshot {
162
+ MonotonicReleaseStateSnapshot(
163
+ purpose: candidate.purpose,
164
+ phase: phase,
165
+ keyEpoch: candidate.keyEpoch,
166
+ releaseSequence: candidate.releaseSequence,
167
+ releaseID: candidate.releaseID,
168
+ cohortPackageSHA256: candidate.cohortPackageSHA256,
169
+ envelopePayloadSHA256: candidate.envelopePayloadSHA256
170
+ )
171
+ }
172
+ }
@@ -0,0 +1,65 @@
1
+ import Foundation
2
+ import Testing
3
+ @testable import RecordingsUpdateProtocol
4
+
5
+ struct ReleaseEnvelopeValidationTests {
6
+ @Test("macOS numeric dotted bundle builds are accepted")
7
+ func numericDottedBundleBuildIsAccepted() throws {
8
+ try payload(build: "0.2.13").validate(
9
+ now: fixedDate("2026-07-19T12:00:00.000Z"),
10
+ brokerVersion: "1.0.0"
11
+ )
12
+ }
13
+
14
+ @Test("nonnumeric bundle build components are rejected")
15
+ func nonnumericBundleBuildIsRejected() {
16
+ #expect(throws: ReleaseEnvelopeValidationError.self) {
17
+ try payload(build: "0.2.beta").validate(
18
+ now: fixedDate("2026-07-19T12:00:00.000Z"),
19
+ brokerVersion: "1.0.0"
20
+ )
21
+ }
22
+ }
23
+
24
+ private func payload(build: String) -> ReleaseEnvelopePayload {
25
+ ReleaseEnvelopePayload(
26
+ schemaVersion: RecordingsUpdateConstants.protocolVersion,
27
+ purpose: "update",
28
+ keyEpoch: 1,
29
+ releaseSequence: 2,
30
+ releaseID: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
31
+ version: "0.2.13",
32
+ build: build,
33
+ sourceCommit: String(repeating: "a", count: 40),
34
+ artifactSHA256: String(repeating: "b", count: 64),
35
+ artifactByteCount: 1024,
36
+ manifestSHA256: String(repeating: "c", count: 64),
37
+ manifestByteCount: 512,
38
+ candidateTreeSHA256: String(repeating: "d", count: 64),
39
+ packageSHA256: String(repeating: "e", count: 64),
40
+ updateClientSHA256: String(repeating: "f", count: 64),
41
+ updateBrokerSHA256: String(repeating: "1", count: 64),
42
+ artifactVerifierSHA256: String(repeating: "2", count: 64),
43
+ bootstrapMarkerSHA256: String(repeating: "3", count: 64),
44
+ architectures: ["arm64", "x86_64"],
45
+ minimumOSVersion: "26.0",
46
+ minimumBrokerVersion: "1.0.0",
47
+ signingTeamIdentifier: "EXAMPLE123",
48
+ applicationDesignatedRequirement: "identifier \"com.hasna.recordings\"",
49
+ updateClientDesignatedRequirement:
50
+ "identifier \"com.hasna.recordings.update-client\"",
51
+ updateBrokerDesignatedRequirement: "identifier \"com.hasna.recordings.updater\"",
52
+ artifactVerifierDesignatedRequirement:
53
+ "identifier \"com.hasna.recordings.artifact-verifier\"",
54
+ installerCertificateSHA256: String(repeating: "4", count: 64),
55
+ issuedAtUTC: "2026-07-19T00:00:00.000Z",
56
+ expiresAtUTC: "2026-07-20T00:00:00.000Z"
57
+ )
58
+ }
59
+
60
+ private func fixedDate(_ value: String) -> Date {
61
+ let formatter = ISO8601DateFormatter()
62
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
63
+ return formatter.date(from: value)!
64
+ }
65
+ }
@@ -0,0 +1,210 @@
1
+ import CryptoKit
2
+ import Darwin
3
+ import Foundation
4
+ import RecordingsUpdateProtocol
5
+
6
+ @main
7
+ enum RecordingsEnvelopeSigner {
8
+ static func main() {
9
+ do {
10
+ let arguments = try Arguments.parse(CommandLine.arguments)
11
+ let payloadData = try readRegularFile(
12
+ path: arguments.payloadPath,
13
+ maximumBytes: 1024 * 1024,
14
+ requireOwnerOnly: false
15
+ )
16
+ let payload = try JSONDecoder().decode(ReleaseEnvelopePayload.self, from: payloadData)
17
+ try payload.validate()
18
+ let privateKeyData = try readRegularFile(
19
+ path: arguments.privateKeyPath,
20
+ maximumBytes: 32,
21
+ requireOwnerOnly: true
22
+ )
23
+ guard privateKeyData.count == 32 else { throw SignerError.invalidPrivateKey }
24
+ let privateKey: Curve25519.Signing.PrivateKey
25
+ do {
26
+ privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyData)
27
+ } catch {
28
+ throw SignerError.invalidPrivateKey
29
+ }
30
+ let publicKeyData = try readRegularFile(
31
+ path: arguments.publicKeyPath,
32
+ maximumBytes: 32,
33
+ requireOwnerOnly: false
34
+ )
35
+ guard publicKeyData.count == 32,
36
+ privateKey.publicKey.rawRepresentation == publicKeyData
37
+ else {
38
+ throw SignerError.publicKeyMismatch
39
+ }
40
+ let signature = try privateKey.signature(for: payload.canonicalData())
41
+ let envelope = SignedReleaseEnvelope(
42
+ payload: payload,
43
+ signature: signature.base64EncodedString()
44
+ )
45
+ let encoder = JSONEncoder()
46
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
47
+ let output = try encoder.encode(envelope) + Data([0x0a])
48
+ try writeExclusive(path: arguments.outputPath, data: output)
49
+ } catch {
50
+ let message = "recordings-envelope-signer: \(sanitized(error))\n"
51
+ FileHandle.standardError.write(Data(message.utf8))
52
+ Darwin.exit(1)
53
+ }
54
+ }
55
+
56
+ private static func readRegularFile(
57
+ path: String,
58
+ maximumBytes: Int64,
59
+ requireOwnerOnly: Bool
60
+ ) throws -> Data {
61
+ let descriptor = Darwin.open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW)
62
+ guard descriptor >= 0 else { throw SignerError.couldNotOpenInput }
63
+ defer { Darwin.close(descriptor) }
64
+ var metadata = stat()
65
+ guard fstat(descriptor, &metadata) == 0,
66
+ (metadata.st_mode & S_IFMT) == S_IFREG,
67
+ metadata.st_size > 0,
68
+ metadata.st_size <= maximumBytes,
69
+ (!requireOwnerOnly || (metadata.st_uid == geteuid() && (metadata.st_mode & 0o077) == 0))
70
+ else {
71
+ throw SignerError.unsafeInput
72
+ }
73
+ let before = metadata
74
+ var data = Data(count: Int(metadata.st_size))
75
+ var offset: off_t = 0
76
+ try data.withUnsafeMutableBytes { bytes in
77
+ guard let base = bytes.baseAddress else { return }
78
+ while offset < before.st_size {
79
+ let count = pread(
80
+ descriptor,
81
+ base.advanced(by: Int(offset)),
82
+ Int(before.st_size - offset),
83
+ offset
84
+ )
85
+ if count < 0, errno == EINTR { continue }
86
+ guard count > 0 else { throw SignerError.couldNotReadInput }
87
+ offset += off_t(count)
88
+ }
89
+ }
90
+ var extra: UInt8 = 0
91
+ guard pread(descriptor, &extra, 1, metadata.st_size) == 0 else {
92
+ throw SignerError.inputChanged
93
+ }
94
+ var after = stat()
95
+ guard fstat(descriptor, &after) == 0,
96
+ before.st_dev == after.st_dev,
97
+ before.st_ino == after.st_ino,
98
+ before.st_size == after.st_size,
99
+ before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec,
100
+ before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec,
101
+ before.st_ctimespec.tv_sec == after.st_ctimespec.tv_sec,
102
+ before.st_ctimespec.tv_nsec == after.st_ctimespec.tv_nsec
103
+ else {
104
+ throw SignerError.inputChanged
105
+ }
106
+ return data
107
+ }
108
+
109
+ private static func writeExclusive(path: String, data: Data) throws {
110
+ let outputURL = URL(fileURLWithPath: path)
111
+ let parentPath = outputURL.deletingLastPathComponent().path
112
+ let leaf = outputURL.lastPathComponent
113
+ guard !leaf.isEmpty, leaf != ".", leaf != "..", !leaf.contains("/") else {
114
+ throw SignerError.outputAlreadyExistsOrUnsafe
115
+ }
116
+ let parent = Darwin.open(parentPath, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)
117
+ guard parent >= 0 else { throw SignerError.outputAlreadyExistsOrUnsafe }
118
+ defer { Darwin.close(parent) }
119
+ var parentMetadata = stat()
120
+ guard fstat(parent, &parentMetadata) == 0,
121
+ (parentMetadata.st_mode & S_IFMT) == S_IFDIR,
122
+ parentMetadata.st_uid == geteuid(),
123
+ (parentMetadata.st_mode & 0o022) == 0
124
+ else {
125
+ throw SignerError.outputAlreadyExistsOrUnsafe
126
+ }
127
+ let descriptor = openat(parent, leaf, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0o600)
128
+ guard descriptor >= 0 else { throw SignerError.outputAlreadyExistsOrUnsafe }
129
+ var keep = false
130
+ defer {
131
+ Darwin.close(descriptor)
132
+ if !keep { unlinkat(parent, leaf, 0) }
133
+ }
134
+ try data.withUnsafeBytes { bytes in
135
+ guard let base = bytes.baseAddress else { return }
136
+ var offset = 0
137
+ while offset < bytes.count {
138
+ let count = Darwin.write(descriptor, base.advanced(by: offset), bytes.count - offset)
139
+ if count < 0, errno == EINTR { continue }
140
+ guard count > 0 else { throw SignerError.couldNotWriteOutput }
141
+ offset += count
142
+ }
143
+ }
144
+ guard fsync(descriptor) == 0, fsync(parent) == 0 else {
145
+ throw SignerError.couldNotWriteOutput
146
+ }
147
+ keep = true
148
+ }
149
+
150
+ private static func sanitized(_ error: Error) -> String {
151
+ if let error = error as? SignerError { return error.description }
152
+ if let error = error as? ReleaseEnvelopeValidationError { return error.description }
153
+ return "operation failed"
154
+ }
155
+ }
156
+
157
+ private struct Arguments {
158
+ let payloadPath: String
159
+ let privateKeyPath: String
160
+ let publicKeyPath: String
161
+ let outputPath: String
162
+
163
+ static func parse(_ values: [String]) throws -> Arguments {
164
+ guard values.count == 9,
165
+ values[1] == "--payload",
166
+ values[3] == "--private-key",
167
+ values[5] == "--public-key",
168
+ values[7] == "--output",
169
+ values[2].hasPrefix("/"),
170
+ values[4].hasPrefix("/"),
171
+ values[6].hasPrefix("/"),
172
+ values[8].hasPrefix("/")
173
+ else {
174
+ throw SignerError.usage
175
+ }
176
+ return Arguments(
177
+ payloadPath: values[2],
178
+ privateKeyPath: values[4],
179
+ publicKeyPath: values[6],
180
+ outputPath: values[8]
181
+ )
182
+ }
183
+ }
184
+
185
+ private enum SignerError: Error, CustomStringConvertible {
186
+ case usage
187
+ case couldNotOpenInput
188
+ case unsafeInput
189
+ case couldNotReadInput
190
+ case inputChanged
191
+ case invalidPrivateKey
192
+ case publicKeyMismatch
193
+ case outputAlreadyExistsOrUnsafe
194
+ case couldNotWriteOutput
195
+
196
+ var description: String {
197
+ switch self {
198
+ case .usage:
199
+ "usage: recordings-envelope-signer --payload /absolute/payload.json --private-key /absolute/private-key.raw --public-key /absolute/public-key.raw --output /absolute/envelope.json"
200
+ case .couldNotOpenInput: "could not open an input file"
201
+ case .unsafeInput: "an input file has an unsafe type, size, owner, or mode"
202
+ case .couldNotReadInput: "could not read an input file"
203
+ case .inputChanged: "an input file changed while it was read"
204
+ case .invalidPrivateKey: "the private signing key is invalid"
205
+ case .publicKeyMismatch: "the private signing key does not match the selected epoch public key"
206
+ case .outputAlreadyExistsOrUnsafe: "the output path already exists or is unsafe"
207
+ case .couldNotWriteOutput: "could not durably write the output file"
208
+ }
209
+ }
210
+ }