├── .gitignore ├── tuple_dev_ed25519.pub ├── tuple_dev_ed25519 ├── LaunchProtocol.h ├── findexe.zig ├── README.md ├── cmsghdr.zig ├── tuplelog.zig ├── signing.zig ├── launch.zig └── flatpak-launch.zig /.gitignore: -------------------------------------------------------------------------------- 1 | zig-cache/ 2 | zig-out/ 3 | -------------------------------------------------------------------------------- /tuple_dev_ed25519.pub: -------------------------------------------------------------------------------- 1 | 2D908247CC669716BF313439ED0A1EEE74674D309280E727AC9393872439F3A4 -------------------------------------------------------------------------------- /tuple_dev_ed25519: -------------------------------------------------------------------------------- 1 | encrypted: 0 2 | data_hex: 4F426B23179071B6B0D60C3AEB870155573C863023416B48C6E4A71A66CA65C22D908247CC669716BF313439ED0A1EEE74674D309280E727AC9393872439F3A4 3 | -------------------------------------------------------------------------------- /LaunchProtocol.h: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Tuple, Inc. All rights reserved. 3 | // 4 | #pragma once 5 | 6 | #define LAUNCH_REQUEST_MAX 200 7 | #define LAUNCH_REPLY_MAX 200 8 | 9 | // tuple-flatpak-launch exe does not come with a valid signature 10 | #define EXIT_CODE_INVALID_SIGNATURE 2 11 | -------------------------------------------------------------------------------- /findexe.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | pub fn findExeBuf(basename: []const u8, path_buf: *[std.fs.MAX_PATH_BYTES]u8) !?usize { 4 | const PATH = std.os.getenvZ("PATH") orelse "/bin:/usr/bin:/usr/local/bin"; 5 | var it = std.mem.tokenize(u8, PATH, ":"); 6 | while (it.next()) |search_path| { 7 | if (path_buf.len < search_path.len + basename.len + 1) continue; 8 | std.mem.copy(u8, path_buf, search_path); 9 | path_buf[search_path.len] = '/'; 10 | std.mem.copy(u8, path_buf[search_path.len + 1 ..], basename); 11 | const path_len = search_path.len + basename.len + 1; 12 | path_buf[path_len] = 0; 13 | const full_path = path_buf[0..path_len :0]; 14 | 15 | var stat: std.os.linux.Stat = undefined; 16 | switch (std.os.errno(std.os.linux.stat(full_path.ptr, &stat))) { 17 | .SUCCESS => {}, 18 | else => continue, 19 | } 20 | return full_path.len; 21 | } 22 | return null; 23 | } 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Tuple Launch 2 | ================================================================================ 3 | Use the following link to download the toolchain for this project: 4 | 5 | https://ziglang.org/builds/zig-linux-x86_64-0.10.0-dev.2674+d980c6a38.tar.xz 6 | 7 | Download/extract the archve and run `zig build` with the resulting `zig` executable. 8 | 9 | This will generate executables in the `zig-out` directory which will include: 10 | 11 | * the `signing` executable for generating new keys/signing and verifying signatures. 12 | * the `tuple-launch` and `tuple-flatpak-launch` executables which are used by Tuple 13 | uses during its launch process. 14 | 15 | Tuple Launch Process 16 | ================================================================================ 17 | Tuple requires privileged access that the flatpak portal doesn't provide. To 18 | accomodate this, Tuple prompts the user to install a privileged daemon. 19 | 20 | The privileged daemon is split into 2 static executables: 21 | 22 | 1. `tuple-launch` 23 | 2. `tuple-flatpak-launch` 24 | 25 | `tuple-launch` is what the user installs to their host filesystem. It gains 26 | root access via SUID. It's only job is to find, verify and launch 27 | `tuple-flatpak-launch`. 28 | 29 | `tuple-flatpak-launch` remains inside the Tuple flatpak so it will be updated 30 | alongside Tuple. Splitting the daemon up like this means the user won't need 31 | to re-install the privileged daemon every time it changes. 32 | 33 | Since `tuple-flatpak-launch` can change, it's important that `tuple-launch` 34 | verifies it came from Tuple. We do this with an ed25519 signature. This 35 | verification is made easier because `tuple-flatpak-launch` is a static 36 | executable. This makes it trival load it into memory, verify it, then execute 37 | it in-place. This eliminates the disk as an attack vector. 38 | -------------------------------------------------------------------------------- /cmsghdr.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | /// TODO: move this to std 4 | 5 | /// This definition enables the use of Zig types with a cmsghdr structure. 6 | /// The oddity of this layout is that the data must be aligned to @sizeOf(usize) 7 | /// rather than its natural alignment. 8 | pub fn Cmsghdr(comptime T: type) type { 9 | const Header = extern struct { 10 | len: usize, 11 | level: c_int, 12 | @"type": c_int, 13 | }; 14 | 15 | const data_align = @sizeOf(usize); 16 | const data_offset = std.mem.alignForward(@sizeOf(Header), data_align); 17 | 18 | return extern struct { 19 | const Self = @This(); 20 | 21 | bytes: [data_offset + @sizeOf(T)]u8 align(@alignOf(Header)), 22 | 23 | pub fn init(args: struct { 24 | level: c_int, 25 | @"type": c_int, 26 | data: T, 27 | }) Self { 28 | var self: Self = undefined; 29 | self.headerPtr().* = .{ 30 | .len = data_offset + @sizeOf(T), 31 | .level = args.level, 32 | .@"type" = args.@"type", 33 | }; 34 | self.dataPtr().* = args.data; 35 | return self; 36 | } 37 | 38 | // TODO: include this version if we submit a PR to add this to std 39 | pub fn initNoData(args: struct { 40 | level: c_int, 41 | @"type": c_int, 42 | }) Self { 43 | var self: Self = undefined; 44 | self.headerPtr().* = .{ 45 | .len = data_offset + @sizeOf(T), 46 | .level = args.level, 47 | .@"type" = args.@"type", 48 | }; 49 | return self; 50 | } 51 | 52 | pub fn headerPtr(self: *Self) *Header { 53 | return @ptrCast(*Header, self); 54 | } 55 | pub fn dataPtr(self: *Self) *align(data_align) T { 56 | return @ptrCast(*T, self.bytes[data_offset..]); 57 | } 58 | }; 59 | } 60 | 61 | test { 62 | std.testing.refAllDecls(Cmsghdr([3]std.os.fd_t)); 63 | } 64 | -------------------------------------------------------------------------------- /tuplelog.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | const stdout = std.io.getStdOut().writer(); 4 | 5 | pub fn JsonStringWriter(comptime UnderlyingWriter: type) type { 6 | return struct { 7 | underlying_writer: UnderlyingWriter, 8 | const Self = @This(); 9 | pub const Error = UnderlyingWriter.Error; 10 | pub const Writer = std.io.Writer(*Self, Error, write); 11 | pub fn writer(self: *Self) Writer { 12 | return .{ .context = self }; 13 | } 14 | pub fn write(self: *Self, bytes: []const u8) Error!usize { 15 | try outputJsonStringPartial(bytes, .{}, self.underlying_writer); 16 | return bytes.len; 17 | } 18 | }; 19 | } 20 | 21 | pub fn Log(comptime role: []const u8) type { 22 | return struct { 23 | pub fn initSigpipeHandler() !void { 24 | const act = std.os.Sigaction{ 25 | .handler = .{ .sigaction = std.os.SIG.IGN }, 26 | .mask = std.os.empty_sigset, 27 | .flags = std.os.SA.SIGINFO, 28 | }; 29 | try std.os.sigaction(std.os.SIG.PIPE, &act, null); 30 | } 31 | 32 | pub var vector_pipe_fd: std.os.fd_t = -1; 33 | pub var team_id: i64 = -1; 34 | pub var user_id: i64 = -1; 35 | 36 | pub const log = struct { 37 | pub fn err(comptime src: std.builtin.SourceLocation, comptime format: []const u8, args: anytype) void { 38 | @setCold(true); 39 | logCommon(src, format, args, .err); 40 | } 41 | pub fn warn(comptime src: std.builtin.SourceLocation, comptime format: []const u8, args: anytype) void { 42 | logCommon(src, format, args, .warn); 43 | } 44 | pub fn info(comptime src: std.builtin.SourceLocation, comptime format: []const u8, args: anytype) void { 45 | logCommon(src, format, args, .info); 46 | } 47 | pub fn debug(comptime src: std.builtin.SourceLocation, comptime format: []const u8, args: anytype) void { 48 | logCommon(src, format, args, .debug); 49 | } 50 | }; 51 | 52 | pub fn logCommon( 53 | comptime src: std.builtin.SourceLocation, 54 | comptime format: []const u8, 55 | args: anytype, 56 | comptime level: std.log.Level, 57 | ) void { 58 | logToStdout(format, args, level) catch |err| 59 | std.debug.panic("log to stdout failed with {s}", .{@errorName(err)}); 60 | if (vector_pipe_fd != -1) { 61 | logToVector(src, format, args, level) catch |err| { 62 | // we'll leave the pipe fd open in case this is the first tuple-launch process 63 | // and we pass the vector pipe fd to the next launch process. It will get the same 64 | // failure but at least the fd number will be valid. Leaving it open doesn't hurt anything. 65 | //std.os.close(vector_pipe_fd); 66 | vector_pipe_fd = -1; 67 | logToStdout("logToVector failed with {s}, closing the vector pipe!", .{@errorName(err)}, .err) 68 | catch |err2| std.debug.panic("can't log to vector nor stdout with {s}", .{@errorName(err2)}); 69 | }; 70 | } 71 | } 72 | 73 | fn logToVector( 74 | comptime src: std.builtin.SourceLocation, 75 | comptime format: []const u8, 76 | args: anytype, 77 | comptime level: std.log.Level, 78 | ) !void { 79 | std.debug.assert(vector_pipe_fd != -1); 80 | const vector_level = switch (level) { 81 | .err => "error", 82 | .warn => "warning", 83 | .info => "info", 84 | .debug => "debug", 85 | }; 86 | var vector_pipe_writer = std.fs.File{ .handle = vector_pipe_fd }; 87 | const BufferedWriter = std.io.BufferedWriter(400, @TypeOf(vector_pipe_writer.writer())); 88 | var buffered = BufferedWriter{ 89 | .unbuffered_writer = vector_pipe_writer.writer(), 90 | }; 91 | try buffered.writer().writeAll("{\"message\": \""); 92 | { 93 | var json_writer = JsonStringWriter(BufferedWriter.Writer) { .underlying_writer = buffered.writer() }; 94 | try std.fmt.format(json_writer.writer(), format, args); 95 | } 96 | const static_fields = "\"" 97 | ++ ", \"level\": \"" ++ vector_level ++ "\"" 98 | ++ ", \"fields\": { \"platform\": \"linux\"" 99 | ; 100 | try buffered.writer().writeAll(static_fields); 101 | const filename = comptime std.fs.path.basename(src.file); 102 | try buffered.writer().print( 103 | ", \"team_id\": {}, \"user_id\": {}" ++ 104 | ", \"source\": {{ \"filename\": \"{s}\", \"line\": {}, \"func\": \"{s}\" }} }} }}\n", .{ 105 | team_id, user_id, filename, src.line, src.fn_name}); 106 | try buffered.flush(); 107 | } 108 | 109 | fn logToStdout( 110 | comptime format: []const u8, 111 | args: anytype, 112 | comptime level: std.log.Level, 113 | ) !void { 114 | var buffered = std.io.BufferedWriter(400, @TypeOf(stdout)){ 115 | .unbuffered_writer = stdout, 116 | }; 117 | var timespec = std.os.timespec{ 118 | .tv_sec = 0, 119 | .tv_nsec = 0, 120 | }; 121 | std.os.clock_gettime(std.os.CLOCK.REALTIME, ×pec) catch { 122 | timespec.tv_sec = 0; 123 | }; 124 | const day_time = std.time.epoch.DaySeconds{ 125 | .secs = std.math.comptimeMod(timespec.tv_sec, std.time.epoch.secs_per_day), 126 | }; 127 | const millis = @floatToInt(u16, @intToFloat(f32, timespec.tv_nsec) / std.time.ns_per_ms); 128 | try buffered.writer().print( 129 | "[UTC:{:0>2}:{:0>2}:{:0>2}:{:0>3}] [" ++ level.asText() ++ "] [" ++ role ++ "] ", 130 | .{ 131 | day_time.getHoursIntoDay(), 132 | day_time.getMinutesIntoHour(), 133 | day_time.getSecondsIntoMinute(), 134 | millis, 135 | }, 136 | ); 137 | try buffered.writer().print(format ++ "\n", args); 138 | try buffered.flush(); 139 | } 140 | }; 141 | } 142 | 143 | // NOTE the following was copied from std, this PR should expose it so we can remove our copy 144 | // https://github.com/ziglang/zig/pull/11972 145 | fn outputUnicodeEscape( 146 | codepoint: u21, 147 | out_stream: anytype, 148 | ) !void { 149 | if (codepoint <= 0xFFFF) { 150 | // If the character is in the Basic Multilingual Plane (U+0000 through U+FFFF), 151 | // then it may be represented as a six-character sequence: a reverse solidus, followed 152 | // by the lowercase letter u, followed by four hexadecimal digits that encode the character's code point. 153 | try out_stream.writeAll("\\u"); 154 | try std.fmt.formatIntValue(codepoint, "x", std.fmt.FormatOptions{ .width = 4, .fill = '0' }, out_stream); 155 | } else { 156 | std.debug.assert(codepoint <= 0x10FFFF); 157 | // To escape an extended character that is not in the Basic Multilingual Plane, 158 | // the character is represented as a 12-character sequence, encoding the UTF-16 surrogate pair. 159 | const high = @intCast(u16, (codepoint - 0x10000) >> 10) + 0xD800; 160 | const low = @intCast(u16, codepoint & 0x3FF) + 0xDC00; 161 | try out_stream.writeAll("\\u"); 162 | try std.fmt.formatIntValue(high, "x", std.fmt.FormatOptions{ .width = 4, .fill = '0' }, out_stream); 163 | try out_stream.writeAll("\\u"); 164 | try std.fmt.formatIntValue(low, "x", std.fmt.FormatOptions{ .width = 4, .fill = '0' }, out_stream); 165 | } 166 | } 167 | fn outputJsonStringPartial(value: []const u8, options: std.json.StringifyOptions, writer: anytype) !void { 168 | var i: usize = 0; 169 | while (i < value.len) : (i += 1) { 170 | switch (value[i]) { 171 | // normal ascii character 172 | 0x20...0x21, 0x23...0x2E, 0x30...0x5B, 0x5D...0x7F => |c| try writer.writeByte(c), 173 | // only 2 characters that *must* be escaped 174 | '\\' => try writer.writeAll("\\\\"), 175 | '\"' => try writer.writeAll("\\\""), 176 | // solidus is optional to escape 177 | '/' => { 178 | if (options.string.String.escape_solidus) { 179 | try writer.writeAll("\\/"); 180 | } else { 181 | try writer.writeByte('/'); 182 | } 183 | }, 184 | // control characters with short escapes 185 | // TODO: option to switch between unicode and 'short' forms? 186 | 0x8 => try writer.writeAll("\\b"), 187 | 0xC => try writer.writeAll("\\f"), 188 | '\n' => try writer.writeAll("\\n"), 189 | '\r' => try writer.writeAll("\\r"), 190 | '\t' => try writer.writeAll("\\t"), 191 | else => { 192 | const ulen = std.unicode.utf8ByteSequenceLength(value[i]) catch unreachable; 193 | // control characters (only things left with 1 byte length) should always be printed as unicode escapes 194 | if (ulen == 1 or options.string.String.escape_unicode) { 195 | const codepoint = std.unicode.utf8Decode(value[i .. i + ulen]) catch unreachable; 196 | try outputUnicodeEscape(codepoint, writer); 197 | } else { 198 | try writer.writeAll(value[i .. i + ulen]); 199 | } 200 | i += ulen - 1; 201 | }, 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /signing.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const crypt = @import("crypt.zig"); 3 | const Signer = std.crypto.sign.Ed25519; 4 | 5 | // Blake3 : 1 6 | // Blake2b512 : 1.3 x slower than Blake3 7 | // Sha256 : 4 x slower than Blake3 8 | //const Hasher = std.crypto.hash.sha2.Sha256; 9 | //const Hasher = std.crypto.hash.blake2.Blake2b512; 10 | const Hasher = std.crypto.hash.Blake3; 11 | 12 | fn fatal(comptime fmt: []const u8, args: anytype) noreturn { 13 | std.log.err(fmt, args); 14 | std.os.exit(0xff); 15 | } 16 | 17 | pub fn main() !u8 { 18 | if (std.os.argv.len <= 1) { 19 | try std.io.getStdOut().writer().writeAll( 20 | "Generate new Key: signing genkey [--encrypt PASSWORD] SECRET_FILENAME PUBLIC_FILENAME\n" ++ 21 | "Sign a file : signing sign [--encrypt PASSWORD] SECRET_FILENAME FILENAME\n" ++ 22 | "Verify a file : signing verify PUBLIC_FILENAME FILENAME\n" ++ 23 | "\n" ++ 24 | "The sign/verify commands assume the signature will reside in FILENAME.sig\n" ++ 25 | "The --encrypt PASSWORD option will store/read the secret key file encrypted using\n" ++ 26 | "the given password to derive the encryption key.\n" 27 | ); 28 | return 0xff; 29 | } 30 | const cmd = std.mem.span(std.os.argv.ptr[1]); 31 | const args = std.os.argv.ptr[2..std.os.argv.len]; 32 | if (std.mem.eql(u8, cmd, "verify")) return try verify(args); 33 | if (std.mem.eql(u8, cmd, "sign")) return try sign(args); 34 | if (std.mem.eql(u8, cmd, "genkey")) return try genkey(args); 35 | std.log.err("unknown command '{s}'", .{cmd}); 36 | return 0xff; 37 | } 38 | 39 | fn getCmdlineOption(args: [][*:0]u8, i: *usize) [*:0]u8 { 40 | i.* += 1; 41 | if (i.* >= args.len) 42 | fatal("option '{s}' requires an argument", .{args[i.* - 1]}); 43 | 44 | return args[i.*]; 45 | } 46 | 47 | fn verify(args: [][*:0]u8) !u8 { 48 | if (args.len != 2) { 49 | std.log.err("verify requires 2 arguments but got {}", .{args.len}); 50 | return 0xff; 51 | } 52 | const public_filename = std.mem.span(args[0]); 53 | const filename_to_verify = std.mem.span(args[1]); 54 | 55 | const public_key = try readFileHex(public_filename, Signer.public_length); 56 | const signature = blk: { 57 | var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 58 | defer arena.deinit(); 59 | const filename = try std.fmt.allocPrint(arena.allocator(), "{s}.sig", .{filename_to_verify}); 60 | break :blk try readFileHex(filename, Signer.signature_length); 61 | }; 62 | 63 | const hash = try hashFile(filename_to_verify); 64 | std.log.info("hash is {}", .{std.fmt.fmtSliceHexUpper(&hash)}); 65 | 66 | Signer.verify(signature, &hash, public_key) catch |err| switch (err) { 67 | error.SignatureVerificationFailed => { 68 | std.log.err("verification failed, data corrupted", .{}); 69 | return 0xff; 70 | }, 71 | else => |e| return e, 72 | }; 73 | std.log.info("Success", .{}); 74 | return 0; 75 | } 76 | 77 | fn sign(all_args: [][*:0]u8) !u8 { 78 | var encrypt_password: ?[]const u8 = null; 79 | const args = blk: { 80 | var i: usize = 0; 81 | var new_arg_count: usize = 0; 82 | while (i < all_args.len) : (i += 1) { 83 | const arg = std.mem.span(all_args[i]); 84 | if (!std.mem.startsWith(u8, arg, "-")) { 85 | all_args[new_arg_count] = arg; 86 | new_arg_count += 1; 87 | } else if (std.mem.eql(u8, arg, "--encrypt")) { 88 | encrypt_password = std.mem.span(getCmdlineOption(all_args, &i)); 89 | } else { 90 | std.log.err("unknown cmd-line option '{s}'", .{arg}); 91 | return 0xff; 92 | } 93 | } 94 | break :blk all_args[0 .. new_arg_count]; 95 | }; 96 | if (args.len != 2) { 97 | std.log.err("sign requires 2 arguments but got {}", .{args.len}); 98 | return 0xff; 99 | } 100 | const secret_filename = std.mem.span(args[0]); 101 | const filename_to_sign = std.mem.span(args[1]); 102 | 103 | const pair = try loadKeyPair(secret_filename, encrypt_password); 104 | const hash = try hashFile(filename_to_sign); 105 | std.log.info("hash is {}", .{std.fmt.fmtSliceHexUpper(&hash)}); 106 | 107 | const signature = try Signer.sign(&hash, pair, null); 108 | std.log.info("signature is {}", .{std.fmt.fmtSliceHexUpper(&signature)}); 109 | { 110 | var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 111 | defer arena.deinit(); 112 | const filename = try std.fmt.allocPrint(arena.allocator(), "{s}.sig", .{filename_to_sign}); 113 | try writeFileHex(filename, &signature); 114 | } 115 | std.log.info("Success", .{}); 116 | return 0; 117 | } 118 | 119 | fn genkey(all_args: [][*:0]u8) !u8 { 120 | var encrypt_password: ?[]const u8 = null; 121 | const args = blk: { 122 | var i: usize = 0; 123 | var new_arg_count: usize = 0; 124 | while (i < all_args.len) : (i += 1) { 125 | const arg = std.mem.span(all_args[i]); 126 | if (!std.mem.startsWith(u8, arg, "-")) { 127 | all_args[new_arg_count] = arg; 128 | new_arg_count += 1; 129 | } else if (std.mem.eql(u8, arg, "--encrypt")) { 130 | encrypt_password = std.mem.span(getCmdlineOption(all_args, &i)); 131 | } else { 132 | std.log.err("unknown cmd-line option '{s}'", .{arg}); 133 | return 0xff; 134 | } 135 | } 136 | break :blk all_args[0 .. new_arg_count]; 137 | }; 138 | if (args.len != 2) { 139 | std.log.err("genkey requires 2 arguments but got {}", .{args.len}); 140 | return 0xff; 141 | } 142 | const secret_filename = std.mem.span(args[0]); 143 | const public_filename = std.mem.span(args[1]); 144 | 145 | const pair = try Signer.KeyPair.create(null); 146 | if (encrypt_password) |password| { 147 | const key = try crypt.passwordToKey(password); 148 | var tag: [crypt.tag_length]u8 = undefined; 149 | var encrypted_secret_key: [Signer.secret_length]u8 = undefined; 150 | try crypt.encrypt(key, &pair.secret_key, &tag, &encrypted_secret_key); 151 | var out_file = try std.fs.cwd().createFile(secret_filename, .{}); 152 | defer out_file.close(); 153 | try out_file.writer().print("encrypted: 1\ntag_hex: {}\ndata_hex: {}\n", .{ 154 | std.fmt.fmtSliceHexUpper(&tag), 155 | std.fmt.fmtSliceHexUpper(&encrypted_secret_key), 156 | }); 157 | } else { 158 | var out_file = try std.fs.cwd().createFile(secret_filename, .{}); 159 | defer out_file.close(); 160 | try out_file.writer().print("encrypted: 0\ndata_hex: {}\n", .{ 161 | std.fmt.fmtSliceHexUpper(&pair.secret_key), 162 | }); 163 | } 164 | try writeFileHex(public_filename, &pair.public_key); 165 | std.log.info("Success", .{}); 166 | return 0; 167 | } 168 | 169 | fn nextNonEmptyLine(it: anytype) ?[]const u8 { 170 | while (it.next()) |raw_line| { 171 | const line = std.mem.trimLeft(u8, raw_line, " "); 172 | if (line.len > 0 and !std.mem.startsWith(u8, line, "#")) 173 | return line; 174 | } 175 | return null; 176 | } 177 | 178 | fn getField(filename: []const u8, it: anytype, expected: []const u8) []const u8 { 179 | const line = nextNonEmptyLine(it) orelse 180 | fatal("invalid secret key file '{s}': ended prematurely (expected '{s}')", .{filename, expected}); 181 | if (!std.mem.startsWith(u8, line, expected)) 182 | fatal("invalid secret key file '{s}': expected '{s}' but got '{s}'", .{filename, expected, line}); 183 | return line[expected.len..]; 184 | } 185 | 186 | fn invalidFieldLen(secret_filename: []const u8, field_name: []const u8, expected: usize, actual: usize) noreturn { 187 | fatal("invalid secret key file '{s}': {s} should be {} characters but is {}", .{secret_filename, field_name, expected, actual}); 188 | } 189 | 190 | fn parseSecretDataHex(secret_filename: []const u8, it: anytype) [Signer.secret_length]u8 { 191 | const data_hex = getField(secret_filename, it, "data_hex: "); 192 | var data: [Signer.secret_length]u8 = undefined; 193 | const len = (std.fmt.hexToBytes(&data, data_hex) catch |err| switch (err) { 194 | error.InvalidCharacter => fatal("invalid secret key file '{s}': data_hex contains invalid hex characters", .{secret_filename}), 195 | error.NoSpaceLeft, error.InvalidLength => invalidFieldLen(secret_filename, "data_hex", data.len, data_hex.len), 196 | }).len; 197 | if (len != data.len) invalidFieldLen(secret_filename, "data_hex", data.len, data_hex.len); 198 | return data; 199 | } 200 | 201 | fn loadKeyPair(secret_filename: []const u8, optional_encrypted_password: ?[]const u8) !Signer.KeyPair { 202 | 203 | var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 204 | defer arena.deinit(); 205 | 206 | const content = blk: { 207 | std.log.info("DEBUG reading '{s}'...", .{secret_filename}); 208 | var file = try std.fs.cwd().openFile(secret_filename, .{}); 209 | defer file.close(); 210 | break :blk try file.readToEndAlloc(arena.allocator(), 999999); 211 | }; 212 | 213 | var it = std.mem.split(u8, content, "\n"); 214 | 215 | const encrypted = blk: { 216 | const encrypted_value = getField(secret_filename, &it, "encrypted: "); 217 | if (std.mem.eql(u8, encrypted_value, "0")) break :blk false; 218 | if (std.mem.eql(u8, encrypted_value, "1")) break :blk true; 219 | fatal("invalid secret key file '{s}': invalid encrypted value '{s}'", .{secret_filename, encrypted_value}); 220 | }; 221 | 222 | const seed = seed_blk: { 223 | if (optional_encrypted_password) |password| { 224 | if (!encrypted) 225 | fatal("secret key file '{s}' is not encrypted but user provided a --encrypt PASSWORD", .{secret_filename}); 226 | const tag = tag_blk: { 227 | const tag_hex = getField(secret_filename, &it, "tag_hex: "); 228 | var tag: [crypt.tag_length]u8 = undefined; 229 | const len = (std.fmt.hexToBytes(&tag, tag_hex) catch |err| switch (err) { 230 | error.InvalidCharacter => fatal("invalid secret key file '{s}': tag_hex contains invalid hex characters", .{secret_filename}), 231 | error.NoSpaceLeft, error.InvalidLength => invalidFieldLen(secret_filename, "tag_hex", tag.len, tag_hex.len), 232 | }).len; 233 | if (len != tag.len) invalidFieldLen(secret_filename, "tag_hex", tag.len, tag_hex.len); 234 | break :tag_blk tag; 235 | }; 236 | const encrypted_data = parseSecretDataHex(secret_filename, &it); 237 | var unencrypted_data: [Signer.secret_length]u8 = undefined; 238 | const key = try crypt.passwordToKey(password); 239 | try crypt.decrypt(key, tag, &encrypted_data, &unencrypted_data); 240 | break :seed_blk unencrypted_data; 241 | } else { 242 | if (encrypted) 243 | fatal("secret key file '{s}' is encrypted, provide the password via --encrypt PASSWORD", .{secret_filename}); 244 | break :seed_blk parseSecretDataHex(secret_filename, &it); 245 | } 246 | }; 247 | if (nextNonEmptyLine(&it)) |extra| 248 | fatal("invalid secret key file '{s}': contains extra data starting at: '{s}'", .{secret_filename, extra}); 249 | return Signer.KeyPair.fromSecretKey(seed); 250 | } 251 | 252 | fn readFileHex(filename: []const u8, comptime len: usize) ![len]u8 { 253 | var file = try std.fs.cwd().openFile(filename, .{}); 254 | defer file.close(); 255 | 256 | const hex_len = len * 2; 257 | var content: [hex_len]u8 = undefined; 258 | { 259 | const read_len = try file.readAll(&content); 260 | if (read_len != hex_len) 261 | fatal("expected '{s}' to be {} bytes but is {}", .{filename, hex_len, read_len}); 262 | } 263 | var bytes: [len]u8 = undefined; 264 | const bytes_len = (try std.fmt.hexToBytes(&bytes, &content)).len; 265 | if (len != bytes_len) 266 | fatal("'{s}' contained invalid hex characters", .{filename}); 267 | return bytes; 268 | } 269 | 270 | fn writeFileHex(filename: []const u8, content: []const u8) !void { 271 | var out_file = try std.fs.cwd().createFile(filename, .{}); 272 | defer out_file.close(); 273 | try out_file.writer().print("{}", .{std.fmt.fmtSliceHexUpper(content)}); 274 | } 275 | 276 | fn hashFile(filename: []const u8) ![Hasher.digest_length]u8 { 277 | var hash = Hasher.init(.{}); 278 | { 279 | var file = try std.fs.cwd().openFile(filename, .{}); 280 | defer file.close(); 281 | while (true) { 282 | var buf: [std.mem.page_size]u8 = undefined; 283 | const len = try file.read(&buf); 284 | if (len == 0) break; 285 | //std.log.info("hashing {} bytes...", .{len}); 286 | hash.update(buf[0 .. len]); 287 | } 288 | } 289 | var result: [Hasher.digest_length]u8 = undefined; 290 | hash.final(&result); 291 | return result; 292 | } 293 | -------------------------------------------------------------------------------- /launch.zig: -------------------------------------------------------------------------------- 1 | const build_options = @import("build_options"); 2 | const std = @import("std"); 3 | const os = std.os; 4 | 5 | const c = @cImport({ 6 | @cInclude("LaunchProtocol.h"); 7 | }); 8 | 9 | const Signer = std.crypto.sign.Ed25519; 10 | const Hasher = std.crypto.hash.Blake3; 11 | 12 | const findexe = @import("findexe.zig"); 13 | const Log = @import("tuplelog.zig").Log("tuple-launch"); 14 | const log = Log.log; 15 | 16 | const FlatpakInstallKind = enum { system, user }; 17 | 18 | var global_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 19 | 20 | fn getCmdlineOption(i: *usize) [*:0]u8 { 21 | i.* += 1; 22 | if (i.* >= os.argv.len) { 23 | log.err(@src(), "command-line option '{s}' requires an argument", .{os.argv[i.*-1]}); 24 | os.exit(0xff); 25 | } 26 | return os.argv[i.*]; 27 | } 28 | 29 | const LaunchExec = switch (build_options.variant) { 30 | .dev => struct { opt_argv_offset: ?usize = null }, 31 | .customer => struct {}, 32 | }; 33 | 34 | pub fn main() !void { 35 | log.info(@src(), "tuple-launch started", .{}); 36 | try Log.initSigpipeHandler(); 37 | 38 | var opt = struct { 39 | flatpak_id: ?[*:0]const u8 = null, 40 | flatpak_install_kind: ?FlatpakInstallKind = null, 41 | }{}; 42 | var launch_exec: LaunchExec = .{}; 43 | 44 | { 45 | var arg_index: usize = 1; 46 | argv_loop: while (arg_index < os.argv.len) : (arg_index += 1) { 47 | const arg = std.mem.span(os.argv[arg_index]); 48 | if (std.mem.eql(u8, arg, "--launch-vector-pipe")) { 49 | const str = std.mem.span(getCmdlineOption(&arg_index)); 50 | Log.vector_pipe_fd = std.fmt.parseInt(os.fd_t, str, 10) catch |err| { 51 | log.err(@src(), "--launch-vector-pipe '{s}' is not an fd number: {s}", .{str, @errorName(err)}); 52 | os.exit(0xff); 53 | }; 54 | log.info(@src(), "vector-pipe set to {s}", .{str}); 55 | } else if (std.mem.eql(u8, arg, "--flatpak-id")) { 56 | opt.flatpak_id = getCmdlineOption(&arg_index); 57 | } else if (std.mem.eql(u8, arg, "--flatpak-system")) { 58 | opt.flatpak_install_kind = .system; 59 | } else if (std.mem.eql(u8, arg, "--flatpak-user")) { 60 | opt.flatpak_install_kind = .user; 61 | } else switch (build_options.variant) { 62 | .customer => {}, 63 | .dev => { 64 | // The --launch-exec option allows the caller to specify the tuple-flatpak-launch 65 | // binary location, along with any number of programs/arguments before, i.e. 66 | // --launch-exec tuple-flatpak-launch 67 | // --launch-exec strace -ff tuple-flatpak-launch 68 | // --launch-exec gdb -ex tuple-flatpak-launch 69 | // Everything after --launch-exec is assumed to be apart of this option. 70 | if (std.mem.eql(u8, arg, "--launch-exec")) { 71 | if (arg_index + 1 >= os.argv.len) { 72 | log.err(@src(), "--launch-exec requires 1 ore more arguments", .{}); 73 | os.exit(0xff); 74 | } 75 | launch_exec = .{ .opt_argv_offset = arg_index + 1 }; 76 | break :argv_loop; 77 | } 78 | }, 79 | } 80 | } 81 | } 82 | 83 | const flatpak_id = std.mem.span(opt.flatpak_id orelse { 84 | log.err(@src(), "missing cmdline option '--flatpak-id'", .{}); 85 | os.exit(0xff); 86 | }); 87 | 88 | const flatpak_install_kind = opt.flatpak_install_kind orelse { 89 | log.err(@src(), "need either --flatpak-system or --flatpak-user", .{}); 90 | os.exit(0xff); 91 | }; 92 | 93 | // only allow our known application ids 94 | switch (build_options.variant) { 95 | .dev => {}, 96 | .customer => { 97 | if (!std.mem.eql(u8, flatpak_id, "app.tuple.app") and !std.mem.eql(u8, flatpak_id, "app.tuple.staging")) { 98 | log.err(@src(), "unauthorized flatpak app id '{s}'", .{flatpak_id}); 99 | os.exit(0xff); 100 | } 101 | }, 102 | } 103 | 104 | const flatpak_exe = (try findExe("flatpak")) orelse { 105 | log.err(@src(), "unable to find the 'flatpak' executable in PATH", .{}); 106 | os.exit(0xff); 107 | }; 108 | 109 | const tuple_flatpak_launch_exe = try getTupleFlatpakLaunchExe(flatpak_id, flatpak_install_kind, flatpak_exe, launch_exec); 110 | const signature = readSigFile(tuple_flatpak_launch_exe) catch |err| switch (err) { 111 | error.FileNotFound => { 112 | log.err(@src(), "'{s}.sig' not found", .{tuple_flatpak_launch_exe}); 113 | os.exit(c.EXIT_CODE_INVALID_SIGNATURE); 114 | }, 115 | else => |e| return e, 116 | }; 117 | 118 | const memfd = try os.memfd_createZ( 119 | tuple_flatpak_launch_exe, 120 | os.linux.MFD.ALLOW_SEALING, 121 | ); 122 | 123 | log.info(@src(), "loading tuple-flatpak-launch into memory...", .{}); 124 | const exe_size = blk: { 125 | var file = try std.fs.cwd().openFile(tuple_flatpak_launch_exe, .{}); 126 | defer file.close(); 127 | const size = try file.getEndPos(); 128 | log.info(@src(), "file size is {} bytes", .{size}); 129 | 130 | var offset: u64 = 0; 131 | while (offset < size) { 132 | const send_result = os.linux.sendfile(memfd, file.handle, @ptrCast(*i64, &offset), size - offset); 133 | switch (os.errno(send_result)) { 134 | .SUCCESS => {}, 135 | else => |errno| { 136 | log.err(@src(), "sendfile for flatpak exe failed (offset={}, errno={})", .{offset, errno}); 137 | os.exit(0xff); 138 | }, 139 | } 140 | log.info(@src(), "wrote {} bytes", .{send_result}); 141 | offset += send_result; 142 | } 143 | break :blk size; 144 | }; 145 | 146 | // Seal the memfd to prevent it from being modified 147 | _ = try os.fcntl( 148 | memfd, 149 | linuxext.F.ADD_SEALS, 150 | linuxext.F.SEAL_SEAL | 151 | linuxext.F.SEAL_SHRINK | 152 | linuxext.F.SEAL_GROW | 153 | linuxext.F.SEAL_WRITE, 154 | ); 155 | 156 | log.info(@src(), "verifying the exe...", .{}); 157 | var hash: [Hasher.digest_length]u8 = undefined; 158 | { 159 | const mem = try os.mmap(null, exe_size, os.PROT.READ, os.MAP.PRIVATE, memfd, 0); 160 | defer os.munmap(mem); 161 | var hasher = Hasher.init(.{}); 162 | hasher.update(mem); 163 | hasher.final(&hash); 164 | } 165 | 166 | // TODO: use the release public key in the customer variant 167 | Signer.verify(signature, &hash, tuple_dev_ed25519_pub) catch |err| switch (err) { 168 | error.SignatureVerificationFailed => { 169 | log.err(@src(), "verification failed, tuple-flatpak-launch exe corrupted", .{}); 170 | os.exit(c.EXIT_CODE_INVALID_SIGNATURE); 171 | }, 172 | else => |e| return e, 173 | }; 174 | 175 | const new_args = try std.heap.page_allocator.alloc(?[*:0]const u8, os.argv.len + 1); 176 | var new_args_len: usize = 0; 177 | 178 | const os_argv_limit = blk: { 179 | switch (build_options.variant) { 180 | .dev => if (launch_exec.opt_argv_offset) |argv_offset| { 181 | const prefix_args = os.argv[argv_offset..os.argv.len - 1]; 182 | for (prefix_args) |arg| { 183 | new_args[new_args_len] = arg; 184 | new_args_len += 1; 185 | } 186 | if (prefix_args.len > 0) { 187 | const exe = std.mem.span(prefix_args[0]); 188 | new_args[0] = (try findExe(exe)) orelse { 189 | log.err(@src(), "unable to find executable '{s}' in PATH", .{exe}); 190 | os.exit(0xff); 191 | }; 192 | } 193 | break :blk argv_offset - 1; 194 | }, 195 | .customer => {}, 196 | } 197 | break :blk os.argv.len; 198 | }; 199 | new_args[new_args_len] = tuple_flatpak_launch_exe.ptr; 200 | new_args_len += 1; 201 | { 202 | var i: usize = 1; 203 | while (i < os_argv_limit) : (i += 1) { 204 | new_args[new_args_len] = os.argv[i]; 205 | new_args_len += 1; 206 | } 207 | } 208 | new_args[new_args_len] = null; 209 | log.info(@src(), "exec '{s}' with {} arguments", .{tuple_flatpak_launch_exe, new_args_len}); 210 | if (false) { 211 | for (new_args[0 .. new_args_len]) |new_arg, i| { 212 | log.info(@src(), "[{}] '{s}'", .{i, new_arg}); 213 | } 214 | } 215 | const execve_err = blk: { 216 | switch (build_options.variant) { 217 | .dev => if (launch_exec.opt_argv_offset) |_| break :blk os.errno(os.linux.execve( 218 | new_args[0].?, 219 | std.meta.assumeSentinel(new_args.ptr, null), 220 | envp(), 221 | )), 222 | .customer => {}, 223 | } 224 | break :blk os.errno(linuxext.execveat( 225 | memfd, 226 | "", 227 | std.meta.assumeSentinel(new_args.ptr, null), 228 | envp(), 229 | os.linux.AT.EMPTY_PATH, 230 | )); 231 | }; 232 | log.err(@src(), "execve '{s}' failed with E{s}", .{tuple_flatpak_launch_exe, @tagName(execve_err)}); 233 | os.exit(0xff); 234 | } 235 | 236 | pub fn getTupleFlatpakLaunchExe( 237 | flatpak_id: []const u8, 238 | flatpak_install_kind: FlatpakInstallKind, 239 | flatpak_exe: [:0]const u8, 240 | launch_exec: LaunchExec, 241 | ) ![:0]const u8 { 242 | switch (build_options.variant) { 243 | .dev => if (launch_exec.opt_argv_offset) |_| return std.mem.span(os.argv[os.argv.len - 1]), 244 | .customer => {}, 245 | } 246 | 247 | var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 248 | defer arena.deinit(); 249 | 250 | var args = std.ArrayListUnmanaged([]const u8) { }; 251 | defer args.deinit(arena.allocator()); 252 | try args.append(arena.allocator(), flatpak_exe); 253 | try args.append(arena.allocator(), "info"); 254 | try args.append(arena.allocator(), switch (flatpak_install_kind) { 255 | .system => "--system", 256 | .user => "--user", 257 | }); 258 | try args.append(arena.allocator(), "--show-location"); 259 | try args.append(arena.allocator(), flatpak_id); 260 | 261 | if (false) { 262 | log.info(@src(), "launching flatpak info...", .{}); 263 | for (args.items) |arg, i| { 264 | log.info(@src(), "[{}] '{s}'", .{i, arg}); 265 | } 266 | } 267 | 268 | // TODO: maybe use less abstraction here to be more efficient? 269 | const result = try std.ChildProcess.exec(.{ 270 | .allocator = arena.allocator(), 271 | .argv = args.items, 272 | }); 273 | if (result.stderr.len > 0) { 274 | log.err(@src(), "flatpak info --show-location stderr: '{s}'", .{result.stderr}); 275 | } 276 | switch (result.term) { 277 | .Exited => |code| { 278 | if (code != 0) { 279 | log.err(@src(), "flatpak info --show-location failed with exit code {} (stdout='{s}')", .{code, result.stdout}); 280 | os.exit(0xff); 281 | } 282 | }, 283 | else => { 284 | log.err(@src(), "flatpak info --show-location terminated with {} (stdout='{s}')", .{result.term, result.stdout}); 285 | os.exit(0xff); 286 | }, 287 | } 288 | const location = std.mem.trimRight(u8, result.stdout, "\n\r "); 289 | log.debug(@src(), "flatpak location is '{s}'", .{location}); 290 | return try std.fmt.allocPrintZ(global_arena.allocator(), "{s}/files/bin/tuple-flatpak-launch", .{location}); 291 | } 292 | 293 | const tuple_dev_ed25519_pub = blk: { 294 | const pub_hex = @embedFile(build_options.pubkey_filepath); 295 | var buf: [Signer.public_length]u8 = undefined; 296 | const len = (std.fmt.hexToBytes(&buf, pub_hex) catch @panic("pub keyfile contained non-hex digits")).len; 297 | std.debug.assert(len == buf.len); 298 | break :blk buf; 299 | }; 300 | 301 | fn openSigFile(content_filename: []const u8) !std.fs.File { 302 | var sig_filename_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; 303 | const sig_filename = try std.fmt.bufPrintZ(&sig_filename_buf, "{s}.sig", .{ content_filename }); 304 | return std.fs.cwd().openFileZ(sig_filename, .{}); 305 | } 306 | 307 | fn readSigFile(content_filename: []const u8) ![Signer.signature_length]u8 { 308 | var file = try openSigFile(content_filename); 309 | defer file.close(); 310 | 311 | const hex_len = Signer.signature_length * 2; 312 | var content: [hex_len]u8 = undefined; 313 | { 314 | const read_len = try file.readAll(&content); 315 | if (read_len != hex_len) { 316 | log.err(@src(), "expected '{s}.sig' to be {} bytes but is {}", .{content_filename, hex_len, read_len}); 317 | std.os.exit(0xff); 318 | } 319 | } 320 | var bytes: [Signer.signature_length]u8 = undefined; 321 | const bytes_len = (try std.fmt.hexToBytes(&bytes, &content)).len; 322 | if (bytes.len != bytes_len) { 323 | log.err(@src(), "'{s}.sig' contained invalid hex characters", .{content_filename}); 324 | std.os.exit(0xff); 325 | } 326 | return bytes; 327 | 328 | } 329 | 330 | fn envp() [*:null]const ?[*:0]const u8 { 331 | return @ptrCast([*:null]const ?[*:0]const u8, os.environ); 332 | } 333 | 334 | fn findExe(basename: []const u8) !?[:0]u8 { 335 | var path_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; 336 | if (try findexe.findExeBuf(basename, &path_buf)) |len| { 337 | return try global_arena.allocator().dupeZ(u8, path_buf[0 ..len]); 338 | } 339 | return null; 340 | } 341 | 342 | // TODO: all this should be in std 343 | const linuxext = struct { 344 | pub const F = struct { 345 | pub const ADD_SEALS = 1033; 346 | pub const SEAL_SEAL = (1 << 0); 347 | pub const SEAL_SHRINK = (1 << 1); 348 | pub const SEAL_GROW = (1 << 2); 349 | pub const SEAL_WRITE = (1 << 3); 350 | }; 351 | pub fn execveat(dirfd: i32, path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8, e: [*:null]const ?[*:0]const u8, flags: u32) usize { 352 | return os.linux.syscall5(.execveat, @bitCast(usize, @as(isize, dirfd)), @ptrToInt(path), @ptrToInt(argv), @ptrToInt(e), @as(usize, flags)); 353 | } 354 | }; 355 | -------------------------------------------------------------------------------- /flatpak-launch.zig: -------------------------------------------------------------------------------- 1 | const build_options = @import("build_options"); 2 | const std = @import("std"); 3 | const os = std.os; 4 | 5 | const Cmsghdr = @import("cmsghdr.zig").Cmsghdr; 6 | 7 | const c = @cImport({ 8 | @cInclude("LaunchProtocol.h"); 9 | }); 10 | 11 | const findexe = @import("findexe.zig"); 12 | const Log = @import("tuplelog.zig").Log("tuple-flatpak-launch"); 13 | const log = Log.log; 14 | 15 | const FlatpakInstallKind = enum { system, user }; 16 | 17 | const LaunchExec = switch (build_options.variant) { 18 | .dev => struct { opt_args: ?[][*:0] u8 = null }, 19 | .customer => struct {}, 20 | }; 21 | 22 | fn getCmdlineOption(i: *usize) [*:0]u8 { 23 | i.* += 1; 24 | if (i.* >= os.argv.len) { 25 | log.err(@src(), "command-line option '{s}' requires an argument", .{os.argv[i.*-1]}); 26 | os.exit(0xff); 27 | } 28 | return os.argv[i.*]; 29 | } 30 | 31 | pub fn main() !void { 32 | log.info(@src(), "tuple-flatpak-launch started", .{}); 33 | try Log.initSigpipeHandler(); 34 | 35 | var opt: struct { 36 | flatpak_install_kind: ?FlatpakInstallKind = null, 37 | flatpak_id: ?[*:0]const u8 = null, 38 | } = .{}; 39 | var launch_exec: LaunchExec = .{}; 40 | 41 | { 42 | var arg_index: usize = 1; 43 | var new_os_argv_len: usize = 1; 44 | argv_loop: while (arg_index < os.argv.len) : (arg_index += 1) { 45 | const arg = std.mem.span(os.argv[arg_index]); 46 | if (std.mem.eql(u8, arg, "--launch-vector-pipe")) { 47 | const str = std.mem.span(getCmdlineOption(&arg_index)); 48 | Log.vector_pipe_fd = std.fmt.parseInt(os.fd_t, str, 10) catch |err| { 49 | log.err(@src(), "--launch-vector-pipe '{s}' is not an fd number: {s}", .{str, @errorName(err)}); 50 | os.exit(0xff); 51 | }; 52 | log.info(@src(), "vector-pipe set to {s}", .{str}); 53 | } else if (std.mem.eql(u8, arg, "--flatpak-id")) { 54 | opt.flatpak_id = getCmdlineOption(&arg_index); 55 | } else if (std.mem.eql(u8, arg, "--flatpak-system")) { 56 | opt.flatpak_install_kind = .system; 57 | } else if (std.mem.eql(u8, arg, "--flatpak-user")) { 58 | opt.flatpak_install_kind = .user; 59 | } else { 60 | switch (build_options.variant) { 61 | .customer => {}, 62 | .dev => { 63 | if (std.mem.eql(u8, arg, "--flatpak-launch-exec")) { 64 | launch_exec = .{ .opt_args = os.argv.ptr[arg_index + 1 .. os.argv.len] }; 65 | break :argv_loop; 66 | } 67 | }, 68 | } 69 | os.argv[new_os_argv_len] = arg.ptr; 70 | new_os_argv_len += 1; 71 | } 72 | } 73 | os.argv = os.argv[0 .. new_os_argv_len]; 74 | } 75 | 76 | const flatpak_id = opt.flatpak_id orelse { 77 | log.err(@src(), "missing --flatpak-id cmdline argument", .{}); 78 | os.exit(0xff); 79 | }; 80 | const flatpak_install_kind = opt.flatpak_install_kind orelse { 81 | log.err(@src(), "need either --flatpak-system or --flatpak-user", .{}); 82 | os.exit(0xff); 83 | }; 84 | 85 | const uids = getUids(); 86 | const tuple_proc = try launchTuple(flatpak_id, flatpak_install_kind, launch_exec, uids); 87 | log.info(@src(), "started tuple (pid={})", .{tuple_proc.pid}); 88 | 89 | // TODO: de-escalate 90 | { 91 | // TODO: is there a full-proof way we could verify that once we de-escalate we'll still have 92 | // access to /dev/input/event*? 93 | } 94 | 95 | const epoll_fd = try os.epoll_create1(os.linux.EPOLL.CLOEXEC); 96 | 97 | // looks like we may not need to listen for SIGCHLD because the socketpair will get 98 | // shutdown instead. 99 | //const signal_fd = try createSignalfd(); 100 | //try epollAdd(epoll_fd, os.linux.EPOLL.CTL_ADD, signal_fd, os.linux.EPOLL.IN, .signal); 101 | 102 | try epollAdd(epoll_fd, os.linux.EPOLL.CTL_ADD, tuple_proc.sock, os.linux.EPOLL.IN, .sock); 103 | 104 | while (true) { 105 | var events: [10]os.linux.epoll_event = undefined; 106 | const event_count = os.epoll_wait(epoll_fd, &events, -1); 107 | for (events[0..event_count]) |*event| { 108 | switch (@intToEnum(EpollHandler, event.data.@"u32")) { 109 | .sock => try onSock(tuple_proc.sock), 110 | } 111 | } 112 | } 113 | } 114 | 115 | const EpollHandler = enum { 116 | //signal, 117 | sock, 118 | }; 119 | fn epollAdd(epoll_fd: os.fd_t, op: u32, fd: os.fd_t, events: u32, handler: EpollHandler) !void { 120 | var event = os.linux.epoll_event{ 121 | .events = events, 122 | .data = .{ .@"u32" = @enumToInt(handler) }, 123 | }; 124 | return os.epoll_ctl(epoll_fd, op, fd, &event); 125 | } 126 | 127 | fn onSock(sock: os.socket_t) !void { 128 | var msg_buf: [c.LAUNCH_REQUEST_MAX]u8 = undefined; 129 | const msg_len = os.read(sock, &msg_buf) catch |err| switch (err) { 130 | error.WouldBlock => return, 131 | else => |e| return e, 132 | }; 133 | if (msg_len == 0) { 134 | log.info(@src(), "tuple socketpair shutdown, exiting...", .{}); 135 | os.exit(0); 136 | } 137 | 138 | const msg = msg_buf[0..msg_len]; 139 | var it = std.mem.tokenize(u8, msg, " "); 140 | const name = it.next() orelse { 141 | sendMsgCt(sock, "error empty message"); 142 | return; 143 | }; 144 | if (std.mem.eql(u8, name, "ping")) { 145 | sendMsgCt(sock, "pong"); 146 | } else if (std.mem.eql(u8, name, "grab-mice")) { 147 | if (it.next()) |_| { 148 | sendMsgCt(sock, "error too many arguments to grab-mice command"); 149 | return; 150 | } 151 | try grabMice(sock); 152 | } else if (std.mem.eql(u8, name, "setids")) { 153 | const team_id_string = it.next() orelse { 154 | sendMsgCt(sock, "error setids command missing team_id"); 155 | return; 156 | }; 157 | const user_id_string = it.next() orelse { 158 | sendMsgCt(sock, "error setids command missing user_id"); 159 | return; 160 | }; 161 | const team_id = std.fmt.parseInt(i64, team_id_string, 10) catch { 162 | sendMsgCt(sock, "error setids team id is invalid"); 163 | return; 164 | }; 165 | const user_id = std.fmt.parseInt(i64, user_id_string, 10) catch { 166 | sendMsgCt(sock, "error setids user id is invalid"); 167 | return; 168 | }; 169 | if (it.next()) |_| { 170 | sendMsgCt(sock, "error too many arguments to setids command"); 171 | return; 172 | } 173 | Log.team_id = team_id; 174 | Log.user_id = user_id; 175 | log.info(@src(), "team/user ids set", .{}); 176 | sendMsgCt(sock, "ok"); 177 | } else { 178 | sendMsgFmt(sock, "error unknown command '{s}'", .{name}); 179 | } 180 | } 181 | 182 | fn rdevMajor(rdev: os.dev_t) u32 { 183 | return @intCast(u32, ((rdev >> 32) & 0xfffff000) | ((rdev >> 8) & 0x00000fff)); 184 | } 185 | fn rdevMinor(rdev: os.dev_t) u32 { 186 | return @intCast(u32, ((rdev >> 12) & 0xffffff00) | (rdev & 0x000000ff)); 187 | } 188 | 189 | fn grabMice(sock: os.socket_t) !void { 190 | var dir = try std.fs.openDirAbsolute("/dev/input", .{ .iterate = true }); 191 | defer dir.close(); 192 | 193 | var dir_it = dir.iterate(); 194 | while (try dir_it.next()) |entry| { 195 | if (entry.kind != .CharacterDevice) continue; 196 | const stat = os.fstatat(dir.fd, entry.name, 0) catch |err| switch (err) { 197 | error.FileNotFound => continue, 198 | else => |e| return e, 199 | }; 200 | 201 | const major = rdevMajor(stat.rdev); 202 | const minor = rdevMinor(stat.rdev); 203 | 204 | if (major != 13) continue; // not an evdev device 205 | const minor_in_range = (minor >= 64 and minor <= 95) or (minor >= 256); 206 | if (!minor_in_range) continue; // not an evdev device 207 | if (!try isMouse(entry.name, major, minor)) continue; 208 | 209 | var open_result = dir.openFile(entry.name, .{}) catch |err| { 210 | sendMsgFmt(sock, "error open '/dev/input/{s}' failed with {s}", .{entry.name, @errorName(err)}); 211 | return; 212 | }; 213 | // TODO: is it ok to close this right after sending it? Seems to be 214 | defer open_result.close(); 215 | sendFd(sock, "fd", open_result.handle); 216 | } 217 | sendMsgCt(sock, "done"); 218 | } 219 | 220 | fn isMouse(entry_name: []const u8, major: u32, minor: u32) !bool { 221 | var path_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; 222 | const path = std.fmt.bufPrintZ(&path_buf, "/sys/dev/char/{}:{}/device", .{ major, minor }) catch unreachable; 223 | var dir = std.fs.openDirAbsolute(path, .{}) catch |err| switch (err) { 224 | error.FileNotFound => { 225 | std.log.warn("device '/dev/input/{s}' has no sysfs entry at '{s}', ignoring", .{entry_name, path}); 226 | return false; // ignore 227 | }, 228 | else => |e| return e, 229 | }; 230 | defer dir.close(); 231 | var rel_file = dir.openFile("capabilities/rel", .{}) catch |err| switch (err) { 232 | error.FileNotFound => return false, // not a mouse 233 | else => |e| return e, 234 | }; 235 | defer rel_file.close(); 236 | const max_hex_string = 8; 237 | var hex_buf: [max_hex_string+1]u8 = undefined; 238 | const len = try rel_file.read(&hex_buf); 239 | if (len == 0 or len > max_hex_string) { 240 | std.log.err("read '{s}' unexpectedly returned {}", .{path, len}); 241 | return error.UnexpectedFileContent; 242 | } 243 | const hex = std.mem.trimRight(u8, hex_buf[0 .. len], "\n"); 244 | const rel_bits = std.fmt.parseInt(u64, hex, 16) catch |err| { 245 | std.log.err("read '{s}' returned invalid hex '{}': {s}", .{path, std.zig.fmtEscapes(hex), @errorName(err)}); 246 | return error.UnexpectedFileContent; 247 | }; 248 | const rel_x = rel_bits & (1 << REL_X); 249 | const rel_y = rel_bits & (1 << REL_Y); 250 | if ((rel_x == 0) and (rel_y == 0)) return false; // not a mouse 251 | 252 | return true; // is a mouse 253 | } 254 | 255 | const REL_X = 0; 256 | const REL_Y = 1; 257 | 258 | const SCM_RIGHTS = 1; 259 | 260 | fn sendFd(sock: os.socket_t, msg: []const u8, fd: os.fd_t) void { 261 | var iov = [_]os.iovec_const{ 262 | .{ 263 | .iov_base = msg.ptr, 264 | .iov_len = msg.len, 265 | }, 266 | }; 267 | var cmsg = Cmsghdr(os.fd_t).init(.{ 268 | .level = os.SOL.SOCKET, 269 | .@"type" = SCM_RIGHTS, 270 | .data = fd, 271 | }); 272 | const len = os.sendmsg(sock, .{ 273 | .name = undefined, 274 | .namelen = 0, 275 | .iov = &iov, 276 | .iovlen = iov.len, 277 | .control = &cmsg, 278 | .controllen = @sizeOf(@TypeOf(cmsg)), 279 | .flags = 0, 280 | }, 0) catch |err| { 281 | // this'll probably fail too, but no harm in trying 282 | sendMsgFmt(sock, "error sendmsg failed with {s}", .{@errorName(err)}); 283 | return; 284 | }; 285 | if (len != msg.len) { 286 | // we don't have much choice but to exit here 287 | log.err(@src(), "expected sendmsg to return {} but got {}", .{msg.len, len}); 288 | os.exit(0xff); 289 | } 290 | } 291 | 292 | fn sendMsgCt(sock: os.socket_t, comptime msg: []const u8) void { 293 | sendMsg(sock, msg); 294 | } 295 | fn sendMsgFmt(sock: os.socket_t, comptime fmt: []const u8, args: anytype) void { 296 | var msg_buf: [c.LAUNCH_REPLY_MAX]u8 = undefined; 297 | const send_msg = std.fmt.bufPrint(&msg_buf, fmt, args) catch |err| switch (err) { 298 | error.NoSpaceLeft => { 299 | log.err(@src(), "unable to send message, it's too big: fmt={s}", .{fmt}); 300 | os.exit(0xff); 301 | }, 302 | }; 303 | sendMsg(sock, send_msg); 304 | } 305 | fn sendMsg(sock: os.socket_t, msg: []const u8) void { 306 | const sent = os.write(sock, msg) catch |err| { 307 | log.err(@src(), "failed to send {}-byte message with {s}", .{msg.len, @errorName(err)}); 308 | os.exit(0xff); // not much to do except exit with a non-zero exit code 309 | }; 310 | // the socket should be SOCK_SEQPACKET so should either be sending the whole message or nothing 311 | std.debug.assert(sent == msg.len); 312 | } 313 | 314 | const TupleProc = struct { 315 | pid: os.pid_t, 316 | sock: os.socket_t, 317 | }; 318 | fn launchTuple( 319 | flatpak_id: [*:0]const u8, 320 | flatpak_install_kind: FlatpakInstallKind, 321 | launch_exec: LaunchExec, 322 | uids: Uids, 323 | ) !TupleProc { 324 | var socks: [2]os.fd_t = undefined; 325 | switch (os.errno(os.linux.socketpair(os.linux.AF.UNIX, os.linux.SOCK.SEQPACKET, 0, &socks))) { 326 | .SUCCESS => {}, 327 | else => |errno| { 328 | log.err(@src(), "failed to create unix socketpair, errno={}", .{errno}); 329 | os.exit(0xff); 330 | }, 331 | } 332 | 333 | var sock_fd_str_buf: [30]u8 = undefined; 334 | const sock_fd_str = try std.fmt.bufPrintZ(&sock_fd_str_buf, "{}", .{socks[1]}); 335 | 336 | const pid = try os.fork(); 337 | if (pid != 0) { 338 | os.close(socks[1]); // close socket meant for tuple 339 | return TupleProc{ .pid = pid, .sock = socks[0] }; 340 | } 341 | 342 | os.close(socks[0]); // close the socket meant for tuple-flatpak-launch 343 | 344 | if (uids.isSuid()) { 345 | const change_to = if (uids.saved != uids.effective) uids.saved else uids.real; 346 | log.info(@src(), "de-escalating to uid {}", .{change_to}); 347 | switch (os.errno(os.linux.setresuid(change_to, change_to, change_to))) { 348 | .SUCCESS => {}, 349 | else => |errno| { 350 | log.err(@src(), "failed to set real/effective/saved uids to {} (errno={}, current={})", .{change_to, errno, uids}); 351 | os.exit(0xff); 352 | }, 353 | } 354 | } 355 | 356 | var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 357 | const al = arena.allocator(); 358 | var args = std.ArrayListUnmanaged(?[*:0]const u8){ }; 359 | 360 | const use_flatpak_run = blk: { 361 | switch (build_options.variant) { 362 | .dev => if (launch_exec.opt_args) |exec_args| { 363 | for (exec_args) |arg| { 364 | try args.append(al, arg); 365 | } 366 | break :blk false; 367 | }, 368 | .customer => {}, 369 | } 370 | break :blk true; 371 | }; 372 | 373 | if (use_flatpak_run) { 374 | const flatpak_exe = (try findExe(arena.allocator(), "flatpak")) orelse { 375 | log.err(@src(), "unable to find the 'flatpak' executable in PATH", .{}); 376 | os.exit(0xff); 377 | }; 378 | try args.append(al, flatpak_exe); 379 | try args.append(al, "run"); 380 | try args.append(al, switch (flatpak_install_kind) { 381 | .system => "--system", 382 | .user => "--user", 383 | }); 384 | try args.append(al, flatpak_id); 385 | } 386 | for (os.argv.ptr[1..os.argv.len]) |arg| { 387 | try args.append(al, arg); 388 | } 389 | try args.append(al, "--launch-sock"); 390 | try args.append(al, sock_fd_str); 391 | try args.append(al, null); 392 | const actual_arg_count = args.items.len - 1; 393 | log.info(@src(), "spawning tuple with {} args", .{actual_arg_count}); 394 | if (false) { 395 | for (args.items[0 .. actual_arg_count]) |arg, i| { 396 | log.info(@src(), "[{}] '{s}'", .{i, arg}); 397 | } 398 | } 399 | const err = os.execveZ(args.items[0].?, std.meta.assumeSentinel(args.items, null), envp()); 400 | log.err(@src(), "execve for next tuple failed with {s}", .{@errorName(err)}); 401 | os.exit(0xff); 402 | } 403 | 404 | fn envp() [*:null]const ?[*:0]const u8 { 405 | return @ptrCast([*:null]const ?[*:0]const u8, os.environ); 406 | } 407 | 408 | const Uids = struct { 409 | real: os.uid_t, 410 | effective: os.uid_t, 411 | saved: os.uid_t, 412 | 413 | pub fn isSuid(self: Uids) bool { 414 | return self.real != self.effective or self.real != self.saved; 415 | } 416 | }; 417 | 418 | fn getUids() Uids { 419 | var uids: Uids = undefined; 420 | switch (os.errno(os.linux.getresuid(&uids.real, &uids.effective, &uids.saved))) { 421 | .SUCCESS => {}, 422 | else => |errno| { 423 | log.err(@src(), "getresuid failed, errno={}", .{errno}); 424 | os.exit(0xff); 425 | }, 426 | } 427 | return uids; 428 | } 429 | 430 | fn findExe(allocator: std.mem.Allocator, basename: []const u8) !?[:0]u8 { 431 | var path_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; 432 | if (try findexe.findExeBuf(basename, &path_buf)) |len| { 433 | return try allocator.dupeZ(u8, path_buf[0 ..len]); 434 | } 435 | return null; 436 | } 437 | --------------------------------------------------------------------------------