diff options
| author | Alexander Leonhardt <equinox.salexander@gmail.com> | |
|---|---|---|
| 2026-06-27 02:29:17 +0200 | ||
| committer | Alexander Leonhardt <equinox.salexander@gmail.com> | |
| 2026-07-01 19:41:49 +0200 | ||
| commit | f3ab0bf0acbd287b45426bb535acea7633b79b2d (patch) | |
| tree | 00277256e934078516d98b03e4bae7193ae94ce5 /src | |
| download | totp-master.tar.gz totp-master.tar.bz2 totp-master.zip | |
Added implementation of the time-based-one-time-password algorithm and on-device encryptionHEADmaster
Diffstat (limited to 'src')
| -rw-r--r-- | src/main.zig | 187 | ||||
| -rw-r--r-- | src/root.zig | 138 |
2 files changed, 325 insertions, 0 deletions
diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..4a70d26 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,187 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; + +const totp = @import("totp"); +const hidapi = @import("hidapi"); + +const CLIError = error { + MissingParameterDeviceId, + MissingParameterVendorId, + MissingKeyParameter, + InvalidBase32, + PasswordTooLong, + UnknowDevice +}; + +/// Receives the base32 encoded code decodes it and stores the output in output. Returns the size of output. +pub fn base32decode(code: []const u8, output: []u8) !usize { + var position:usize = 0; + var value:u64 = 0; + var bits:usize = 0; + if (std.mem.findScalar(u8, code, ' ')) |_| { + std.log.warn("Key contains whitespace which is not valid base32, ignoring",.{}); + } + for(code,0..) |b,pos| { + if (b==' ') { + continue; + } + else if (b>'Z' or b < '2' or (b>'9' and b < 'A')) { + std.log.err("The character at position {}: {c} is invalid base32",.{pos,b}); + return CLIError.InvalidBase32; + } + const val = if (b >= 'A') b-'A' else (b-'2')+26; + + value = (value<<5) | val; + bits += 5; + if (bits >= 8) { + bits -= 8; + output[position] = @as(u8,@intCast((value>>@intCast(bits))&0xff)); + position+=1; + } + } + return position; +} + +/// Prints the given prompt before asking for user input. reader is assumed to be a reader for stdin. +/// if with_delim is set to true returns the user input in addition to the final carriage return +pub fn read_password(prompt: []const u8, reader: *std.Io.Reader, with_delim: bool) ![]u8 { + std.debug.print("{s}",.{prompt}); + const stdin = std.Io.File.stdin(); + const original_termios = try std.posix.tcgetattr(stdin.handle); + var new_termios = original_termios; + new_termios.lflag.ECHO = false; + try std.posix.tcsetattr(stdin.handle, .FLUSH, new_termios); + defer std.posix.tcsetattr(stdin.handle, .FLUSH, original_termios) catch {}; + + const password = if (with_delim) try reader.takeDelimiterInclusive('\n') else (try reader.takeDelimiter('\n')).?; + std.debug.print("\n",.{}); + return password; +} + +pub fn main(init: std.process.Init) !void { + var iter = init.minimal.args.iterate(); + _ = iter.next(); + + var buffer: [256]u8 = undefined; + if(iter.next()) |arg| { + const stdin = std.Io.File.stdin(); + var reader = stdin.reader(init.io, &buffer); + if (std.mem.eql(u8,arg, "add")) { + var output_buffer: [128]u8 = undefined; + const key_enc = if (iter.next()) |key| key else return CLIError.MissingKeyParameter; + const key_len = try base32decode(key_enc, &output_buffer); + const key = try init.gpa.dupe(u8,output_buffer[0..key_len]); + defer init.gpa.free(key); + const password = try read_password("Password: ", &reader.interface, false); + + var output: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(password, &output, .{}); + std.crypto.aead.chacha_poly.XChaCha20Poly1305.encrypt(output_buffer[0..key.len], output_buffer[key.len..][0..16], key, "", totp.nonce, output); + const slice = std.base64.standard_no_pad.Encoder.encode(&buffer, output_buffer[0..key.len+16]); + std.debug.print("Add this struct entry to the configuration {{KEY,6,{s}}}\n",.{slice}); + } + else if (std.mem.eql(u8,arg, "decode")) { + const key_enc = if (iter.next()) |key| key else return CLIError.MissingKeyParameter; + const password = try read_password("Password: ", &reader.interface, true); + password[password.len-1] = 0; + var output: [32]u8 = undefined; + _ = totp.decode_secret_and_generate_2fa(password.ptr, key_enc,@as(u64,@intCast(std.Io.Timestamp.now(init.io,.real).toSeconds())), 6, output[0..]); + std.debug.print("2FA-Key: {s}\n", .{output[0..6]}); + } + else if (std.mem.eql(u8,arg, "reencode")) { + var output_buffer: [128]u8 = undefined; + const key_enc = if (iter.next()) |key| key else return CLIError.MissingKeyParameter; + const password = try read_password("Old Password: ", &reader.interface, true); + password[password.len-1] = 0; + totp.sha256(password.ptr, output_buffer[0..]); + var secret_2fa: [128]u8 = undefined; + const result = totp.decode_2fa_key(key_enc, output_buffer[0..], secret_2fa[0..]); + if (result < 0) { + std.debug.print("Error decoding\n",.{}); + return; + } + const length:usize = @intCast(result); + const newpassword = try read_password("New Password: ", &reader.interface, false); + var output: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(newpassword, &output, .{}); + std.crypto.aead.chacha_poly.XChaCha20Poly1305.encrypt(output_buffer[0..length], output_buffer[length..][0..16], secret_2fa[0..length], "", totp.nonce, output); + const slice = std.base64.standard_no_pad.Encoder.encode(&buffer, output_buffer[0..length+16]); + std.debug.print("Add this struct entry to the configuration {{KEY,6,{s}}}\n",.{slice}); + } + else if(std.mem.eql(u8,arg,"nonce")) { + var op = try std.Io.Dir.openFileAbsolute(init.io,"/dev/urandom",.{}); + var reader_nonce = op.reader(init.io, &buffer); + var bytes:[24]u8 = undefined; + _ = try reader_nonce.interface.readSliceShort(&bytes); + std.debug.print("Nonce {any}\n",.{bytes}); + } + else if(std.mem.eql(u8,arg,"hid")) { + const vendorid = if (iter.next()) |vendorid| try std.fmt.parseInt(u16, vendorid, 16) else return CLIError.MissingParameterVendorId; + const deviceid = if (iter.next()) |devid| try std.fmt.parseInt(u16, devid, 16) else return CLIError.MissingParameterDeviceId; + _ = hidapi.hid_init(); + defer _ = hidapi.hid_exit(); + var enumerate = hidapi.hid_enumerate(vendorid, deviceid); + while(enumerate) |interface| { + if (interface.*.usage == 0x61 and interface.*.usage_page == 0xFF60) { + break; + } + enumerate = enumerate.*.next; + } + if(enumerate == null) { + std.debug.print("Could not find device.\n", .{}); + return CLIError.UnknowDevice; + } + const dev: ?*hidapi.hid_device = hidapi.hid_open_path(enumerate.*.path); + std.debug.print("Found device at {s}\n", .{enumerate.*.path}); + if (dev) |device_handle| { + defer hidapi.hid_close(device_handle); + var write_buffer: [33]u8 = undefined; + // Report ID + write_buffer[0] = 0; + + const now:u64 = @intCast(std.Io.Timestamp.now(init.io, .real).toSeconds()); + + // Payload + inline for(0..8) |x| { + write_buffer[1+x] = @intCast((now>>(x*8))&0xFF); + } + const password = try read_password("Password: ", &reader.interface, false); + // Password length is bounded since HIDAPI report length is bounded + if (password.len > 23) { + std.debug.print("Only passwords lengths of at most 23 bytes supported.\n", .{}); + return CLIError.PasswordTooLong; + } + + @memcpy(write_buffer[9..9+password.len], password); + write_buffer[9+password.len] = 0; + const res2 = hidapi.hid_write(device_handle, &write_buffer, 33); + if(res2 == 33) { + std.debug.print("Set up keyboard.\n", .{}); + } + } + else { + std.debug.print("Could not open device with vendor id: {x}, device id: {x}\n", .{vendorid, deviceid}); + } + } + } + else { + std.debug.print("Usage: totp [command]\nCommunicates and sets up time base one time passwords for programmable keyboards.\nCommands:\n\tadd key\n\t\tAdds a new base32 encoded key.\n\n\tdecode key\n\t\tDecodes the key and generates one time password based on it.\n\n\tnonce\n\t\tGenerates a new nonce for the symmetric cipher.\n\n\thid vendorid deviceid\n\t\tSets up HID device. IDS are interpreted as base 16 numbers.\n",.{}); + } +} + + +test "base32 decode" { + var output: [20]u8 = undefined; + const encoded = "AABBDDEEFFHH"; + const size = try base32decode(encoded, &output); + + const encoded_err = "AABBDDEEFFHH--"; + try std.testing.expectError(CLIError.InvalidBase32,base32decode(encoded_err,&output)); + + var output_2: [20]u8 = undefined; + const encoded_spaces = "AABBD DEEFF HH"; + const size2 = try base32decode(encoded_spaces, &output_2); + try std.testing.expectEqual(size, size2); + try std.testing.expect(std.mem.eql(u8, output[0..size], output_2[0..size2])); +} diff --git a/src/root.zig b/src/root.zig new file mode 100644 index 0000000..e4ff7a8 --- /dev/null +++ b/src/root.zig @@ -0,0 +1,138 @@ +const std = @import("std"); + +// TODO: Must be replaced by each user by his/her own nonce (for example from /dev/urandom). +pub const nonce = [_]u8{143, 54, 214, 156, 114, 22, 191, 156, 175, 116, 138, 187, 248, 246, 227, 91, 108, 217, 79, 141, 154, 107, 51, 122}; + +/// input is a zero terminated byte string to be hashed. +/// output is a buffer of at least 32 bytes +pub export fn sha256(input: [*c]const u8, output: [*c]u8) void { + std.crypto.hash.sha2.Sha256.hash(std.mem.span(input), output[0..32], .{}); +} + + +/// This code never aborts (asserts or throws an exception) +/// password_hashed is the sha256 hashed password with exactly 32 bytes length +/// encoded_secret is the base64 encoded encrypted secret for the TOTP +/// timestamp is the seconds since the unix epoch +/// length refers to the supposed length of the TOTP key, range 4-10 +/// output must be a buffer of at least 32 bytes, since it is reused throughout this function as buffer +pub export fn decode_secret_and_generate_2fa(password_hashed: [*c]const u8, encoded_secret: [*c]const u8, timestamp: u64, length: u8, output: [*c]u8) bool { + var buffer: [128]u8 = undefined; + const result = decode_2fa_key(std.mem.span(encoded_secret),password_hashed[0..32],buffer[0..]); + if(result < 0) return false; + hotp(buffer[0..@intCast(result)], timestamp/30, length, output[0..32]); + return true; +} + +/// This code never aborts (asserts or throws an exception) +/// encoded_secret is the base64 encoded encrypted secret for TOTP +/// password is the hashed password of exactly 32 bytes length +/// output_buffer holds the decrypted TOTP secret +/// The return value holds the number of bytes written to the output buffer +pub fn decode_2fa_key(encoded_secret: []const u8, password: []const u8, output_buffer: []u8) i16 { + if (password.len != 32) return -1; + var buffer: [128]u8 = undefined; + const size = std.base64.standard_no_pad.Decoder.calcSizeForSlice(encoded_secret) catch return -2; + if (size>buffer.len or size-16 > output_buffer.len) return -5; + std.base64.standard_no_pad.Decoder.decode(&buffer, encoded_secret) catch return -3; + const decodedb = buffer[0..size]; + + std.crypto.aead.chacha_poly.XChaCha20Poly1305.decrypt(output_buffer[0..size-16], decodedb[0..size-16], decodedb[size-16..][0..16].*, "", nonce, password[0..32].*) catch return -4; + return @as(i16,@intCast(size))-16; +} + +/// Generates a HOTP key given +/// key is the plain bytes of the TOTP secret +/// counter is the counter parameter for HOTP (seconds since epoch/30) for TOTP +/// length refers to the outputted key length +/// output_buffer contains the zero terminated code in ASCII format (must have a size of at least 20 bytes) +pub fn hotp(key: []const u8, counter: u64, length: u8, output_buffer: []u8) void { + std.crypto.auth.hmac.HmacSha1.create(output_buffer[0..20], &@as([8]u8,@bitCast(std.mem.nativeToBig(u64,counter))), key); + const offset = output_buffer[19] & 0xf; + var truncated: [4]u8 = [4]u8{output_buffer[offset],output_buffer[offset+1],output_buffer[offset+2],output_buffer[offset+3]}; + truncated[0] &= 0x7f; + + var value = std.mem.nativeToBig(u32,@as(u32,@bitCast(truncated))) % (std.math.pow(u32,10,@intCast(length))); + for(1..length+1) |i| { + const modulo = value % 10; + value = value/10; + output_buffer[length-i] = @as(u8,@intCast(modulo))+'0'; + } + output_buffer[length] = 0; +} + + + +test "Check decode" { + var buffer: [128]u8 = undefined; + const payload = [_]u8{143, 54, 214, 156, 114, 22}; + var output_buffer: [128]u8 = undefined; + const testpassword = "Test123"; + var output: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(testpassword, &output, .{}); + std.crypto.aead.chacha_poly.XChaCha20Poly1305.encrypt(output_buffer[0..payload.len], output_buffer[payload.len..][0..16], payload[0..], "", nonce, output); + const slice = std.base64.standard_no_pad.Encoder.encode(&buffer, output_buffer[0..payload.len+16]); + const dupe = try std.testing.allocator.dupeZ(u8, slice); + defer std.testing.allocator.free(dupe); + + + try std.testing.expect(decode_2fa_key(dupe, output[0..], output_buffer[0..])>0); + try std.testing.expectEqualStrings(output_buffer[0..payload.len], &payload); +} + + +test "Check hotp output" { + var output_buffer: [128]u8 = undefined; + const hello_world:[:0]const u8 = "Hello, world!"; + hotp(hello_world, 42, 6, output_buffer[0..]); + + try std.testing.expectEqualStrings(output_buffer[0..6], "439256"); +} + +test "Check output size of hotp" { + var output_buffer: [128]u8 = undefined; + const hello_world:[:0]const u8 = "Hello, world!"; + for(4..10) |length| { + hotp(hello_world, 42, @intCast(length), output_buffer[0..]); + + try std.testing.expectEqual(std.mem.span(@as([*c]u8, @ptrCast(output_buffer[0..]))).len, length); + } +} + +test "Check full pipeline" { + var buffer: [128]u8 = undefined; + const hello_world:[:0]const u8 = "Hello, world!"; + var output_buffer: [128]u8 = undefined; + const testpassword = "Test123"; + var output: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(testpassword, &output, .{}); + std.crypto.aead.chacha_poly.XChaCha20Poly1305.encrypt(output_buffer[0..hello_world.len], output_buffer[hello_world.len..][0..16], hello_world, "", nonce, output); + const slice = std.base64.standard_no_pad.Encoder.encode(&buffer, output_buffer[0..hello_world.len+16]); + const dupe = try std.testing.allocator.dupeZ(u8, slice); + defer std.testing.allocator.free(dupe); + + try std.testing.expect(decode_secret_and_generate_2fa(output[0..], dupe, 42*30, 6, output_buffer[0..])); + + try std.testing.expectEqualStrings(output_buffer[0..6], "439256"); + + try std.testing.expect(decode_secret_and_generate_2fa(output[0..], dupe, 43*30, 6, output_buffer[0..])); + try std.testing.expectEqualStrings(output_buffer[0..6], "208805"); +} + + +test "Check fail on wrong password" { + var buffer: [128]u8 = undefined; + const hello_world:[:0]const u8 = "Hello, world!"; + var output_buffer: [128]u8 = undefined; + const testpassword = "Test123"; + var output: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(testpassword, &output, .{}); + std.crypto.aead.chacha_poly.XChaCha20Poly1305.encrypt(output_buffer[0..hello_world.len], output_buffer[hello_world.len..][0..16], hello_world, "", nonce, output); + const slice = std.base64.standard_no_pad.Encoder.encode(&buffer, output_buffer[0..hello_world.len+16]); + const dupe = try std.testing.allocator.dupeZ(u8, slice); + defer std.testing.allocator.free(dupe); + + output[10] = ~output[10]; + + try std.testing.expect(!decode_secret_and_generate_2fa(output[0..], dupe, 42*30, 6, output_buffer[0..])); +} |