@pgpm/totp 0.4.0

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.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Dan Lynch <pyramation@gmail.com>
4
+ Copyright (c) 2025 Interweb, Inc.
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/Makefile ADDED
@@ -0,0 +1,6 @@
1
+ EXTENSION = launchql-totp
2
+ DATA = sql/launchql-totp--0.4.6.sql
3
+
4
+ PG_CONFIG = pg_config
5
+ PGXS := $(shell $(PG_CONFIG) --pgxs)
6
+ include $(PGXS)
package/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # @pgpm/totp
2
+
3
+ TOTP implementation in pure PostgreSQL plpgsql
4
+
5
+ This extension provides the HMAC Time-Based One-Time Password Algorithm (TOTP) as specfied in RFC 4226 as pure plpgsql functions.
6
+
7
+ # Usage
8
+
9
+ ## totp.generate
10
+
11
+ ```sql
12
+ SELECT totp.generate('mysecret');
13
+
14
+ -- you can also specify totp_interval, and totp_length
15
+ SELECT totp.generate('mysecret', 30, 6);
16
+ ```
17
+
18
+ In this case, produces a TOTP code of length 6
19
+
20
+ ```
21
+ 013438
22
+ ```
23
+
24
+ ## totp.verify
25
+
26
+ ```sql
27
+ SELECT totp.verify('mysecret', '765430');
28
+
29
+ -- you can also specify totp_interval, and totp_length
30
+ SELECT totp.verify('mysecret', '765430', 30, 6);
31
+ ```
32
+
33
+ Depending on input, returns `TRUE/FALSE`
34
+
35
+ ## totp.url
36
+
37
+ ```sql
38
+ -- totp.url ( email text, totp_secret text, totp_interval int, totp_issuer text )
39
+ SELECT totp.url(
40
+ 'customer@email.com',
41
+ 'mysecret',
42
+ 30,
43
+ 'Acme Inc'
44
+ );
45
+ ```
46
+
47
+ Will produce a URL-encoded string
48
+
49
+ ```
50
+ otpauth://totp/customer@email.com?secret=mysecret&period=30&issuer=Acme%20Inc
51
+ ```
52
+
53
+ # caveats
54
+
55
+ Currently only supports `sha1`, pull requests welcome!
56
+
57
+ # debugging
58
+
59
+ use the verbose option to show keys
60
+
61
+ ```sh
62
+ $ oathtool --totp -v -d 7 -s 10s -b OH3NUPO3WOGOZZQ4
63
+ Hex secret: 71f6da3ddbb38cece61c
64
+ Base32 secret: OH3NUPO3WOGOZZQ4
65
+ Digits: 7
66
+ Window size: 0
67
+ TOTP mode: SHA1
68
+ Step size (seconds): 10
69
+ Start time: 1970-01-01 00:00:00 UTC (0)
70
+ Current time: 2020-11-18 12:35:08 UTC (1605702908)
71
+ Counter: 0x9921BB2 (160570290)
72
+ ```
73
+
74
+ using time for testing
75
+
76
+ oathtool --totp -v -d 6 -s 30s -b vmlhl2knm27eftq7 --now "2020-02-05 22:11:40 UTC"
77
+
78
+
79
+ # credits
80
+
81
+ Thanks to
82
+
83
+ https://tools.ietf.org/html/rfc6238
84
+
85
+ https://www.youtube.com/watch?v=VOYxF12K1vE
86
+
87
+ https://pgxn.org/dist/otp/
88
+
89
+ And major improvements from
90
+
91
+ https://gist.github.com/bwbroersma/676d0de32263ed554584ab132434ebd9
92
+
93
+ # Development
94
+
95
+ ## start the postgres db process
96
+
97
+ First you'll want to start the postgres docker (you can also just use `docker-compose up -d`):
98
+
99
+ ```sh
100
+ make up
101
+ ```
102
+
103
+ ## install modules
104
+
105
+ Install modules
106
+
107
+ ```sh
108
+ yarn install
109
+ ```
110
+
111
+ ## install the Postgres extensions
112
+
113
+ Now that the postgres process is running, install the extensions:
114
+
115
+ ```sh
116
+ make install
117
+ ```
118
+
119
+ This basically `ssh`s into the postgres instance with the `packages/` folder mounted as a volume, and installs the bundled sql code as pgxn extensions.
120
+
121
+ ## testing
122
+
123
+ Testing will load all your latest sql changes and create fresh, populated databases for each sqitch module in `packages/`.
124
+
125
+ ```sh
126
+ yarn test:watch
127
+ ```
128
+
129
+ ## building new modules
130
+
131
+ Create a new folder in `packages/`
132
+
133
+ ```sh
134
+ lql init
135
+ ```
136
+
137
+ Then, run a generator:
138
+
139
+ ```sh
140
+ lql generate
141
+ ```
142
+
143
+ You can also add arguments if you already know what you want to do:
144
+
145
+ ```sh
146
+ lql generate schema --schema myschema
147
+ lql generate table --schema myschema --table mytable
148
+ ```
149
+
150
+ ## deploy code as extensions
151
+
152
+ `cd` into `packages/<module>`, and run `lql package`. This will make an sql file in `packages/<module>/sql/` used for `CREATE EXTENSION` calls to install your sqitch module as an extension.
153
+
154
+ ## recursive deploy
155
+
156
+ You can also deploy all modules utilizing versioning as sqtich modules. Remove `--createdb` if you already created your db:
157
+
158
+ ```sh
159
+ lql deploy awesome-db --yes --recursive --createdb
160
+ ```
@@ -0,0 +1,25 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`issue case: 1 1`] = `"476240"`;
4
+
5
+ exports[`issue case: 2 1`] = `"788648"`;
6
+
7
+ exports[`issue case: 3 1`] = `"080176"`;
8
+
9
+ exports[`rfc6238 case: 1 1`] = `"94287082"`;
10
+
11
+ exports[`rfc6238 case: 2 1`] = `"07081804"`;
12
+
13
+ exports[`rfc6238 case: 3 1`] = `"14050471"`;
14
+
15
+ exports[`rfc6238 case: 4 1`] = `"89005924"`;
16
+
17
+ exports[`rfc6238 case: 5 1`] = `"69279037"`;
18
+
19
+ exports[`rfc6238 case: 6 1`] = `"65353130"`;
20
+
21
+ exports[`speakeasy test case: 1 1`] = `"287082"`;
22
+
23
+ exports[`speakeasy test case: 2 1`] = `"081804"`;
24
+
25
+ exports[`speakeasy test case: 3 1`] = `"360094"`;
@@ -0,0 +1,142 @@
1
+ import { getConnections, PgTestClient } from 'pgsql-test';
2
+ import cases from 'jest-in-case';
3
+
4
+ let pg: PgTestClient;
5
+ let teardown: () => Promise<void>;
6
+
7
+ beforeAll(async () => {
8
+ ({ pg, teardown } = await getConnections());
9
+ });
10
+
11
+ afterAll(async () => {
12
+ await teardown();
13
+ });
14
+
15
+
16
+ cases(
17
+ 'rfc6238',
18
+ async (opts: { date: string; len: number; algo: string; result: string }) => {
19
+ const { generate } = await pg.one(
20
+ `SELECT totp.generate(
21
+ secret := $1,
22
+ period := 30,
23
+ digits := $2,
24
+ time_from := $3,
25
+ hash := $4,
26
+ encoding := NULL
27
+ )`,
28
+ ['12345678901234567890', opts.len, opts.date, opts.algo]
29
+ );
30
+ expect(generate).toEqual(opts.result);
31
+ expect(generate).toMatchSnapshot();
32
+ },
33
+ [
34
+ { date: '1970-01-01 00:00:59', len: 8, algo: 'sha1', result: '94287082' },
35
+ { date: '2005-03-18 01:58:29', len: 8, algo: 'sha1', result: '07081804' },
36
+ { date: '2005-03-18 01:58:31', len: 8, algo: 'sha1', result: '14050471' },
37
+ { date: '2009-02-13 23:31:30', len: 8, algo: 'sha1', result: '89005924' },
38
+ { date: '2033-05-18 03:33:20', len: 8, algo: 'sha1', result: '69279037' },
39
+ { date: '2603-10-11 11:33:20', len: 8, algo: 'sha1', result: '65353130' }
40
+ ]
41
+ );
42
+
43
+ cases(
44
+ 'speakeasy test',
45
+ async (opts: { date: string; len: number; algo: string; step: number; result: string }) => {
46
+ const { generate } = await pg.one(
47
+ `SELECT totp.generate(
48
+ secret := $1,
49
+ period := $5,
50
+ digits := $2,
51
+ time_from := $3,
52
+ hash := $4,
53
+ encoding := NULL
54
+ )`,
55
+ ['12345678901234567890', opts.len, opts.date, opts.algo, opts.step]
56
+ );
57
+ expect(generate).toEqual(opts.result);
58
+ expect(generate).toMatchSnapshot();
59
+ },
60
+ [
61
+ { date: '1970-01-01 00:00:59', len: 6, step: 30, algo: 'sha1', result: '287082' },
62
+ { date: '2005-03-18 01:58:29', len: 6, step: 30, algo: 'sha1', result: '081804' },
63
+ { date: '2005-03-18 01:58:29', len: 6, step: 60, algo: 'sha1', result: '360094' }
64
+ ]
65
+ );
66
+
67
+ cases(
68
+ 'verify',
69
+ async (opts: { date: string; len: number; algo?: string; step: number; result: string }) => {
70
+ const [{ verified }] = await pg.any(
71
+ `SELECT * FROM totp.verify(
72
+ secret := $1,
73
+ check_totp := $2,
74
+ period := $3,
75
+ digits := $4,
76
+ time_from := $5,
77
+ encoding := NULL
78
+ ) as verified`,
79
+ ['12345678901234567890', opts.result, opts.step, opts.len, opts.date]
80
+ );
81
+ expect(verified).toBe(true);
82
+ },
83
+ [
84
+ { date: '1970-01-01 00:00:59', len: 6, step: 30, algo: 'sha1', result: '287082' },
85
+ { date: '2005-03-18 01:58:29', len: 6, step: 30, algo: 'sha1', result: '081804' },
86
+ { date: '2005-03-18 01:58:29', len: 6, step: 60, algo: 'sha1', result: '360094' },
87
+ { date: '1970-01-01 00:00:59', len: 8, step: 30, algo: 'sha1', result: '94287082' },
88
+ { date: '2005-03-18 01:58:29', len: 8, step: 30, algo: 'sha1', result: '07081804' },
89
+ { date: '2005-03-18 01:58:31', len: 8, step: 30, algo: 'sha1', result: '14050471' },
90
+ { date: '2009-02-13 23:31:30', len: 8, step: 30, algo: 'sha1', result: '89005924' },
91
+ { date: '2033-05-18 03:33:20', len: 8, algo: 'sha1', step: 30, result: '69279037' },
92
+ { date: '2603-10-11 11:33:20', len: 8, algo: 'sha1', step: 30, result: '65353130' }
93
+ ]
94
+ );
95
+
96
+ cases(
97
+ 'issue',
98
+ async (opts: { encoding: string | null; secret: string; date: string; len: number; step: number; algo: string; result: string }) => {
99
+ const { generate } = await pg.one(
100
+ `SELECT totp.generate(
101
+ secret := $1,
102
+ period := $2,
103
+ digits := $3,
104
+ time_from := $4,
105
+ hash := $5,
106
+ encoding := $6
107
+ )`,
108
+ [opts.secret, opts.step, opts.len, opts.date, opts.algo, opts.encoding]
109
+ );
110
+ expect(generate).toEqual(opts.result);
111
+ expect(generate).toMatchSnapshot();
112
+ },
113
+ [
114
+ {
115
+ encoding: null,
116
+ secret: 'OH3NUPO3WOGOZZQ4',
117
+ date: '2020-11-14 07:46:37.212048+00',
118
+ len: 6,
119
+ step: 30,
120
+ algo: 'sha1',
121
+ result: '476240'
122
+ },
123
+ {
124
+ encoding: 'base32',
125
+ secret: 'OH3NUPO3WOGOZZQ4',
126
+ date: '2020-11-14 07:46:37.212048+00',
127
+ len: 6,
128
+ step: 30,
129
+ algo: 'sha1',
130
+ result: '788648'
131
+ },
132
+ {
133
+ encoding: 'base32',
134
+ secret: 'OH3NUPO',
135
+ date: '2020-11-14 07:46:37.212048+00',
136
+ len: 6,
137
+ step: 30,
138
+ algo: 'sha1',
139
+ result: '080176'
140
+ }
141
+ ]
142
+ );
@@ -0,0 +1,26 @@
1
+ import { getConnections, PgTestClient } from 'pgsql-test';
2
+
3
+ let pg: PgTestClient;
4
+ let teardown: () => Promise<void>;
5
+
6
+ beforeAll(async () => {
7
+ ({ pg, teardown } = await getConnections());
8
+ });
9
+
10
+ afterAll(async () => {
11
+ await teardown();
12
+ });
13
+
14
+
15
+ it('totp.generate + totp.verify basic', async () => {
16
+ const { generate } = await pg.one(
17
+ `SELECT totp.generate($1::text) AS generate`,
18
+ ['secret']
19
+ );
20
+ const { verify } = await pg.one(
21
+ `SELECT totp.verify($1::text, $2::text) AS verify`,
22
+ ['secret', generate]
23
+ );
24
+ expect(typeof generate).toBe('string');
25
+ expect(verify).toBe(true);
26
+ });
@@ -0,0 +1,168 @@
1
+ -- Deploy schemas/totp/procedures/generate_totp to pg
2
+ -- requires: schemas/totp/schema
3
+ -- requires: schemas/totp/procedures/urlencode
4
+
5
+ BEGIN;
6
+
7
+ -- https://www.youtube.com/watch?v=VOYxF12K1vE
8
+ -- https://tools.ietf.org/html/rfc6238
9
+ -- http://blog.tinisles.com/2011/10/google-authenticator-one-time-password-algorithm-in-javascript/
10
+ -- https://gist.github.com/bwbroersma/676d0de32263ed554584ab132434ebd9
11
+
12
+ CREATE FUNCTION totp.pad_secret (
13
+ input bytea,
14
+ len int
15
+ ) returns bytea as $$
16
+ DECLARE
17
+ output bytea;
18
+ orig_length int = octet_length(input);
19
+ BEGIN
20
+ IF (orig_length = len) THEN
21
+ RETURN input;
22
+ END IF;
23
+
24
+ -- create blank bytea size of new length
25
+ output = lpad('', len, 'x')::bytea;
26
+
27
+ FOR i IN 0 .. len-1 LOOP
28
+ output = set_byte(output, i, get_byte(input, i % orig_length));
29
+ END LOOP;
30
+
31
+ RETURN output;
32
+ END;
33
+ $$
34
+ LANGUAGE 'plpgsql' IMMUTABLE;
35
+
36
+ CREATE FUNCTION totp.base32_to_hex (
37
+ input text
38
+ ) returns text as $$
39
+ DECLARE
40
+ output text[];
41
+ decoded text = base32.decode(input);
42
+ len int = character_length(decoded);
43
+ hx text;
44
+ BEGIN
45
+
46
+ FOR i IN 1 .. len LOOP
47
+ hx = to_hex(ascii(substring(decoded from i for 1)))::text;
48
+ IF (character_length(hx) = 1) THEN
49
+ -- if it is odd number of digits, pad a 0 so it can later
50
+ hx = '0' || hx;
51
+ END IF;
52
+ output = array_append(output, hx);
53
+ END LOOP;
54
+
55
+ RETURN array_to_string(output, '');
56
+ END;
57
+ $$
58
+ LANGUAGE 'plpgsql' IMMUTABLE;
59
+
60
+ CREATE FUNCTION totp.hotp(key BYTEA, c INT, digits INT DEFAULT 6, hash TEXT DEFAULT 'sha1') RETURNS TEXT AS $$
61
+ DECLARE
62
+ c BYTEA := '\x' || LPAD(TO_HEX(c), 16, '0');
63
+ mac BYTEA := HMAC(c, key, hash);
64
+ trunc_offset INT := GET_BYTE(mac, length(mac) - 1) % 16;
65
+ result TEXT := SUBSTRING(SET_BIT(SUBSTRING(mac FROM 1 + trunc_offset FOR 4), 7, 0)::TEXT, 2)::BIT(32)::INT % (10 ^ digits)::INT;
66
+ BEGIN
67
+ RETURN LPAD(result, digits, '0');
68
+ END;
69
+ $$ LANGUAGE plpgsql IMMUTABLE;
70
+
71
+ CREATE FUNCTION totp.generate(
72
+ secret text,
73
+ period int DEFAULT 30,
74
+ digits int DEFAULT 6,
75
+ time_from timestamptz DEFAULT NOW(),
76
+ hash text DEFAULT 'sha1',
77
+ encoding text DEFAULT 'base32',
78
+ clock_offset int DEFAULT 0
79
+ ) RETURNS text AS $$
80
+ DECLARE
81
+ c int := FLOOR(EXTRACT(EPOCH FROM time_from) / period)::int + clock_offset;
82
+ key bytea;
83
+ BEGIN
84
+
85
+ IF (encoding = 'base32') THEN
86
+ key = ( '\x' || totp.base32_to_hex(secret) )::bytea;
87
+ ELSE
88
+ key = secret::bytea;
89
+ END IF;
90
+
91
+ RETURN totp.hotp(key, c, digits, hash);
92
+ END;
93
+ $$ LANGUAGE plpgsql STABLE;
94
+
95
+ -- Mitigate timing attacks by using constant-time comparison.
96
+ -- Mitigates timing attacks by avoiding early-exit and content-dependent work; compares full byte sequences in a length-oblivious loop.
97
+ -- Context: HN discussion on TOTP '=' comparison timing leaks: https://news.ycombinator.com/item?id=26260667
98
+
99
+ -- Context: https://news.ycombinator.com/item?id=26260667
100
+
101
+ CREATE FUNCTION totp.timing_safe_equals(a bytea, b bytea)
102
+ RETURNS boolean
103
+ AS $$
104
+ DECLARE
105
+ la int := length(a);
106
+ lb int := length(b);
107
+ maxlen int := GREATEST(la, lb);
108
+ i int;
109
+ diff int := la # lb;
110
+ ca int;
111
+ cb int;
112
+ BEGIN
113
+ FOR i IN 0..(maxlen - 1) LOOP
114
+ ca := CASE WHEN i < la THEN get_byte(a, i) ELSE 0 END;
115
+ cb := CASE WHEN i < lb THEN get_byte(b, i) ELSE 0 END;
116
+ diff := diff | (ca # cb);
117
+ END LOOP;
118
+ RETURN diff = 0;
119
+ END;
120
+ $$ LANGUAGE plpgsql IMMUTABLE STRICT;
121
+
122
+ CREATE FUNCTION totp.timing_safe_equals(a text, b text)
123
+ RETURNS boolean
124
+ AS $$
125
+ -- Verify uses timing-safe equality to avoid leaking mismatch position via timing; do not use direct '=' here.
126
+ -- See HN discussion for background: https://news.ycombinator.com/item?id=26260667
127
+
128
+ SELECT totp.timing_safe_equals(convert_to(a, 'UTF8'), convert_to(b, 'UTF8'));
129
+ $$ LANGUAGE sql IMMUTABLE STRICT;
130
+
131
+ CREATE FUNCTION totp.verify (
132
+ secret text,
133
+ check_totp text,
134
+ period int default 30,
135
+ digits int default 6,
136
+ time_from timestamptz DEFAULT NOW(),
137
+ hash text default 'sha1',
138
+ encoding text DEFAULT 'base32',
139
+ clock_offset int default 0
140
+ )
141
+ RETURNS boolean
142
+ AS $$
143
+ SELECT totp.timing_safe_equals(
144
+ totp.generate(
145
+ secret,
146
+ period,
147
+ digits,
148
+ time_from,
149
+ hash,
150
+ encoding,
151
+ clock_offset
152
+ ),
153
+ check_totp
154
+ );
155
+ $$
156
+ LANGUAGE 'sql';
157
+
158
+ CREATE FUNCTION totp.url (email text, totp_secret text, totp_interval int, totp_issuer text)
159
+ RETURNS text
160
+ AS $$
161
+ SELECT
162
+ concat('otpauth://totp/', totp.urlencode (email), '?secret=', totp.urlencode (totp_secret), '&period=', totp.urlencode (totp_interval::text), '&issuer=', totp.urlencode (totp_issuer));
163
+ $$
164
+ LANGUAGE 'sql'
165
+ STRICT IMMUTABLE;
166
+
167
+ COMMIT;
168
+
@@ -0,0 +1,38 @@
1
+ -- Uses pgcrypto's gen_random_bytes for cryptographically secure randomness; random() is not suitable for secrets. Preserves RFC 4648 base32 alphabet output.
2
+
3
+ -- Deploy schemas/totp/procedures/random_base32 to pg
4
+ -- requires: schemas/totp/schema
5
+
6
+ BEGIN;
7
+
8
+ CREATE FUNCTION totp.random_base32 (_length int DEFAULT 20)
9
+ RETURNS text
10
+ LANGUAGE sql
11
+ AS $$
12
+ SELECT
13
+ string_agg(
14
+ ('{a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z,2,3,4,5,6,7}'::text[])
15
+ [ (get_byte(b, i) % 32) + 1 ],
16
+ ''
17
+ )
18
+ FROM (SELECT gen_random_bytes(_length) AS b) t,
19
+ LATERAL generate_series(0, _length - 1) g(i);
20
+ $$;
21
+
22
+ CREATE FUNCTION totp.generate_secret(hash TEXT DEFAULT 'sha1') RETURNS BYTEA AS $$
23
+ BEGIN
24
+ -- See https://tools.ietf.org/html/rfc4868#section-2.1.2
25
+ -- The optimal key length for HMAC is the block size of the algorithm
26
+ CASE
27
+ WHEN hash = 'sha1' THEN RETURN totp.random_base32(20); -- = 160 bits
28
+ WHEN hash = 'sha256' THEN RETURN totp.random_base32(32); -- = 256 bits
29
+ WHEN hash = 'sha512' THEN RETURN totp.random_base32(64); -- = 512 bits
30
+ ELSE
31
+ RAISE EXCEPTION 'Unsupported hash algorithm for OTP (see RFC6238/4226).';
32
+ RETURN NULL;
33
+ END CASE;
34
+ END;
35
+ $$ LANGUAGE plpgsql VOLATILE;
36
+
37
+ COMMIT;
38
+
@@ -0,0 +1,43 @@
1
+ -- Deploy schemas/totp/procedures/urlencode to pg
2
+ -- requires: schemas/totp/schema
3
+
4
+ -- https://stackoverflow.com/questions/10318014/javascript-encodeuri-like-function-in-postgresql/40762846
5
+ BEGIN;
6
+ CREATE FUNCTION totp.urlencode (in_str text)
7
+ RETURNS text
8
+ AS $$
9
+ DECLARE
10
+ _i int4;
11
+ _temp varchar;
12
+ _ascii int4;
13
+ _result text := '';
14
+ BEGIN
15
+ FOR _i IN 1..length(in_str)
16
+ LOOP
17
+ _temp := substr(in_str, _i, 1);
18
+ IF _temp ~ '[0-9a-zA-Z:/@._?#-]+' THEN
19
+ _result := _result || _temp;
20
+ ELSE
21
+ _ascii := ascii(_temp);
22
+ IF _ascii > x'07ff'::int4 THEN
23
+ RAISE exception 'won''t deal with 3 (or more) byte sequences.';
24
+ END IF;
25
+ IF _ascii <= x'07f'::int4 THEN
26
+ _temp := '%' || to_hex(_ascii);
27
+ ELSE
28
+ _temp := '%' || to_hex((_ascii & x'03f'::int4) + x'80'::int4);
29
+ _ascii := _ascii >> 6;
30
+ _temp := '%' || to_hex((_ascii & x'01f'::int4) + x'c0'::int4) || _temp;
31
+ END IF;
32
+ _result := _result || upper(_temp);
33
+ END IF;
34
+ END LOOP;
35
+ RETURN _result;
36
+ END;
37
+ $$
38
+ LANGUAGE 'plpgsql'
39
+ STRICT IMMUTABLE
40
+ ;
41
+
42
+ COMMIT;
43
+
@@ -0,0 +1,8 @@
1
+ -- Deploy schemas/totp/schema to pg
2
+
3
+
4
+ BEGIN;
5
+
6
+ CREATE SCHEMA totp;
7
+
8
+ COMMIT;
package/jest.config.js ADDED
@@ -0,0 +1,15 @@
1
+ /** @type {import('ts-jest').JestConfigWithTsJest} */
2
+ module.exports = {
3
+ preset: 'ts-jest',
4
+ testEnvironment: 'node',
5
+
6
+ // Match both __tests__ and colocated test files
7
+ testMatch: ['**/?(*.)+(test|spec).{ts,tsx,js,jsx}'],
8
+
9
+ // Ignore build artifacts and type declarations
10
+ testPathIgnorePatterns: ['/dist/', '\\.d\\.ts$'],
11
+ modulePathIgnorePatterns: ['<rootDir>/dist/'],
12
+ watchPathIgnorePatterns: ['/dist/'],
13
+
14
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
15
+ };
@@ -0,0 +1,8 @@
1
+ # launchql-totp extension
2
+ comment = 'launchql-totp extension'
3
+ default_version = '0.4.6'
4
+ module_pathname = '$libdir/launchql-totp'
5
+ requires = 'pgcrypto,plpgsql,launchql-base32,launchql-verify'
6
+ relocatable = false
7
+ superuser = false
8
+
package/launchql.plan ADDED
@@ -0,0 +1,8 @@
1
+ %syntax-version=1.0.0
2
+ %project=launchql-totp
3
+ %uri=launchql-totp
4
+
5
+ schemas/totp/schema 2017-08-11T08:11:51Z skitch <skitch@5b0c196eeb62> # add schemas/totp/schema
6
+ schemas/totp/procedures/urlencode [schemas/totp/schema] 2017-08-11T08:11:51Z skitch <skitch@5b0c196eeb62> # add schemas/totp/procedures/urlencode
7
+ schemas/totp/procedures/generate_totp [schemas/totp/schema schemas/totp/procedures/urlencode] 2017-08-11T08:11:51Z skitch <skitch@5b0c196eeb62> # add schemas/totp/procedures/generate_totp
8
+ schemas/totp/procedures/random_base32 [schemas/totp/schema] 2017-08-11T08:11:51Z skitch <skitch@5b0c196eeb62> # add schemas/totp/procedures/random_base32
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@pgpm/totp",
3
+ "version": "0.4.0",
4
+ "description": "Time-based One-Time Password (TOTP) authentication",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "scripts": {
9
+ "bundle": "lql package",
10
+ "test": "jest",
11
+ "test:watch": "jest --watch"
12
+ },
13
+ "dependencies": {
14
+ "@pgpm/base32": "0.4.0",
15
+ "@pgpm/verify": "0.4.0"
16
+ },
17
+ "devDependencies": {
18
+ "@launchql/cli": "^4.9.0"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/launchql/extensions"
23
+ },
24
+ "homepage": "https://github.com/launchql/extensions",
25
+ "bugs": {
26
+ "url": "https://github.com/launchql/extensions/issues"
27
+ },
28
+ "gitHead": "cc9f52a335caa6e21ee7751b04b77c84ce6cb809"
29
+ }
@@ -0,0 +1,14 @@
1
+ -- Revert schemas/totp/procedures/generate_totp from pg
2
+
3
+ BEGIN;
4
+
5
+ DROP FUNCTION totp.url;
6
+ DROP FUNCTION totp.verify;
7
+ DROP FUNCTION totp.timing_safe_equals(a text, b text);
8
+ DROP FUNCTION totp.timing_safe_equals(a bytea, b bytea);
9
+ DROP FUNCTION totp.generate;
10
+ DROP FUNCTION totp.hotp;
11
+ DROP FUNCTION totp.base32_to_hex;
12
+ DROP FUNCTION totp.pad_secret;
13
+
14
+ COMMIT;
@@ -0,0 +1,8 @@
1
+ -- Revert schemas/totp/procedures/random_base32 from pg
2
+
3
+ BEGIN;
4
+
5
+ DROP FUNCTION totp.generate_secret;
6
+ DROP FUNCTION totp.random_base32;
7
+
8
+ COMMIT;
@@ -0,0 +1,7 @@
1
+ -- Revert schemas/totp/procedures/urlencode from pg
2
+
3
+ BEGIN;
4
+
5
+ DROP FUNCTION totp.urlencode;
6
+
7
+ COMMIT;
@@ -0,0 +1,7 @@
1
+ -- Revert schemas/totp/schema from pg
2
+
3
+ BEGIN;
4
+
5
+ DROP SCHEMA totp CASCADE;
6
+
7
+ COMMIT;
@@ -0,0 +1,173 @@
1
+ \echo Use "CREATE EXTENSION launchql-totp" to load this file. \quit
2
+ CREATE SCHEMA totp;
3
+
4
+ CREATE FUNCTION totp.urlencode(in_str text) RETURNS text AS $EOFCODE$
5
+ DECLARE
6
+ _i int4;
7
+ _temp varchar;
8
+ _ascii int4;
9
+ _result text := '';
10
+ BEGIN
11
+ FOR _i IN 1..length(in_str)
12
+ LOOP
13
+ _temp := substr(in_str, _i, 1);
14
+ IF _temp ~ '[0-9a-zA-Z:/@._?#-]+' THEN
15
+ _result := _result || _temp;
16
+ ELSE
17
+ _ascii := ascii(_temp);
18
+ IF _ascii > x'07ff'::int4 THEN
19
+ RAISE exception 'won''t deal with 3 (or more) byte sequences.';
20
+ END IF;
21
+ IF _ascii <= x'07f'::int4 THEN
22
+ _temp := '%' || to_hex(_ascii);
23
+ ELSE
24
+ _temp := '%' || to_hex((_ascii & x'03f'::int4) + x'80'::int4);
25
+ _ascii := _ascii >> 6;
26
+ _temp := '%' || to_hex((_ascii & x'01f'::int4) + x'c0'::int4) || _temp;
27
+ END IF;
28
+ _result := _result || upper(_temp);
29
+ END IF;
30
+ END LOOP;
31
+ RETURN _result;
32
+ END;
33
+ $EOFCODE$ LANGUAGE plpgsql STRICT IMMUTABLE;
34
+
35
+ CREATE FUNCTION totp.pad_secret(input bytea, len int) RETURNS bytea AS $EOFCODE$
36
+ DECLARE
37
+ output bytea;
38
+ orig_length int = octet_length(input);
39
+ BEGIN
40
+ IF (orig_length = len) THEN
41
+ RETURN input;
42
+ END IF;
43
+
44
+ -- create blank bytea size of new length
45
+ output = lpad('', len, 'x')::bytea;
46
+
47
+ FOR i IN 0 .. len-1 LOOP
48
+ output = set_byte(output, i, get_byte(input, i % orig_length));
49
+ END LOOP;
50
+
51
+ RETURN output;
52
+ END;
53
+ $EOFCODE$ LANGUAGE plpgsql IMMUTABLE;
54
+
55
+ CREATE FUNCTION totp.base32_to_hex(input text) RETURNS text AS $EOFCODE$
56
+ DECLARE
57
+ output text[];
58
+ decoded text = base32.decode(input);
59
+ len int = character_length(decoded);
60
+ hx text;
61
+ BEGIN
62
+
63
+ FOR i IN 1 .. len LOOP
64
+ hx = to_hex(ascii(substring(decoded from i for 1)))::text;
65
+ IF (character_length(hx) = 1) THEN
66
+ -- if it is odd number of digits, pad a 0 so it can later
67
+ hx = '0' || hx;
68
+ END IF;
69
+ output = array_append(output, hx);
70
+ END LOOP;
71
+
72
+ RETURN array_to_string(output, '');
73
+ END;
74
+ $EOFCODE$ LANGUAGE plpgsql IMMUTABLE;
75
+
76
+ CREATE FUNCTION totp.hotp(key bytea, c int, digits int DEFAULT 6, hash text DEFAULT 'sha1') RETURNS text AS $EOFCODE$
77
+ DECLARE
78
+ c BYTEA := '\x' || LPAD(TO_HEX(c), 16, '0');
79
+ mac BYTEA := HMAC(c, key, hash);
80
+ trunc_offset INT := GET_BYTE(mac, length(mac) - 1) % 16;
81
+ result TEXT := SUBSTRING(SET_BIT(SUBSTRING(mac FROM 1 + trunc_offset FOR 4), 7, 0)::TEXT, 2)::BIT(32)::INT % (10 ^ digits)::INT;
82
+ BEGIN
83
+ RETURN LPAD(result, digits, '0');
84
+ END;
85
+ $EOFCODE$ LANGUAGE plpgsql IMMUTABLE;
86
+
87
+ CREATE FUNCTION totp.generate(secret text, period int DEFAULT 30, digits int DEFAULT 6, time_from timestamptz DEFAULT now(), hash text DEFAULT 'sha1', encoding text DEFAULT 'base32', clock_offset int DEFAULT 0) RETURNS text AS $EOFCODE$
88
+ DECLARE
89
+ c int := FLOOR(EXTRACT(EPOCH FROM time_from) / period)::int + clock_offset;
90
+ key bytea;
91
+ BEGIN
92
+
93
+ IF (encoding = 'base32') THEN
94
+ key = ( '\x' || totp.base32_to_hex(secret) )::bytea;
95
+ ELSE
96
+ key = secret::bytea;
97
+ END IF;
98
+
99
+ RETURN totp.hotp(key, c, digits, hash);
100
+ END;
101
+ $EOFCODE$ LANGUAGE plpgsql STABLE;
102
+
103
+ CREATE FUNCTION totp.timing_safe_equals(a bytea, b bytea) RETURNS boolean AS $EOFCODE$
104
+ DECLARE
105
+ la int := length(a);
106
+ lb int := length(b);
107
+ maxlen int := GREATEST(la, lb);
108
+ i int;
109
+ diff int := la # lb;
110
+ ca int;
111
+ cb int;
112
+ BEGIN
113
+ FOR i IN 0..(maxlen - 1) LOOP
114
+ ca := CASE WHEN i < la THEN get_byte(a, i) ELSE 0 END;
115
+ cb := CASE WHEN i < lb THEN get_byte(b, i) ELSE 0 END;
116
+ diff := diff | (ca # cb);
117
+ END LOOP;
118
+ RETURN diff = 0;
119
+ END;
120
+ $EOFCODE$ LANGUAGE plpgsql IMMUTABLE STRICT;
121
+
122
+ CREATE FUNCTION totp.timing_safe_equals(a text, b text) RETURNS boolean AS $EOFCODE$
123
+ -- Verify uses timing-safe equality to avoid leaking mismatch position via timing; do not use direct '=' here.
124
+ -- See HN discussion for background: https://news.ycombinator.com/item?id=26260667
125
+
126
+ SELECT totp.timing_safe_equals(convert_to(a, 'UTF8'), convert_to(b, 'UTF8'));
127
+ $EOFCODE$ LANGUAGE sql IMMUTABLE STRICT;
128
+
129
+ CREATE FUNCTION totp.verify(secret text, check_totp text, period int DEFAULT 30, digits int DEFAULT 6, time_from timestamptz DEFAULT now(), hash text DEFAULT 'sha1', encoding text DEFAULT 'base32', clock_offset int DEFAULT 0) RETURNS boolean AS $EOFCODE$
130
+ SELECT totp.timing_safe_equals(
131
+ totp.generate(
132
+ secret,
133
+ period,
134
+ digits,
135
+ time_from,
136
+ hash,
137
+ encoding,
138
+ clock_offset
139
+ ),
140
+ check_totp
141
+ );
142
+ $EOFCODE$ LANGUAGE sql;
143
+
144
+ CREATE FUNCTION totp.url(email text, totp_secret text, totp_interval int, totp_issuer text) RETURNS text AS $EOFCODE$
145
+ SELECT
146
+ concat('otpauth://totp/', totp.urlencode (email), '?secret=', totp.urlencode (totp_secret), '&period=', totp.urlencode (totp_interval::text), '&issuer=', totp.urlencode (totp_issuer));
147
+ $EOFCODE$ LANGUAGE sql STRICT IMMUTABLE;
148
+
149
+ CREATE FUNCTION totp.random_base32(_length int DEFAULT 20) RETURNS text LANGUAGE sql AS $EOFCODE$
150
+ SELECT
151
+ string_agg(
152
+ ('{a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z,2,3,4,5,6,7}'::text[])
153
+ [ (get_byte(b, i) % 32) + 1 ],
154
+ ''
155
+ )
156
+ FROM (SELECT gen_random_bytes(_length) AS b) t,
157
+ LATERAL generate_series(0, _length - 1) g(i);
158
+ $EOFCODE$;
159
+
160
+ CREATE FUNCTION totp.generate_secret(hash text DEFAULT 'sha1') RETURNS bytea AS $EOFCODE$
161
+ BEGIN
162
+ -- See https://tools.ietf.org/html/rfc4868#section-2.1.2
163
+ -- The optimal key length for HMAC is the block size of the algorithm
164
+ CASE
165
+ WHEN hash = 'sha1' THEN RETURN totp.random_base32(20); -- = 160 bits
166
+ WHEN hash = 'sha256' THEN RETURN totp.random_base32(32); -- = 256 bits
167
+ WHEN hash = 'sha512' THEN RETURN totp.random_base32(64); -- = 512 bits
168
+ ELSE
169
+ RAISE EXCEPTION 'Unsupported hash algorithm for OTP (see RFC6238/4226).';
170
+ RETURN NULL;
171
+ END CASE;
172
+ END;
173
+ $EOFCODE$ LANGUAGE plpgsql VOLATILE;
@@ -0,0 +1,7 @@
1
+ -- Verify schemas/totp/procedures/generate_totp on pg
2
+
3
+ BEGIN;
4
+
5
+ SELECT verify_function ('totp.generate');
6
+
7
+ ROLLBACK;
@@ -0,0 +1,7 @@
1
+ -- Verify schemas/totp/procedures/random_base32 on pg
2
+
3
+ BEGIN;
4
+
5
+ SELECT verify_function ('totp.random_base32');
6
+
7
+ ROLLBACK;
@@ -0,0 +1,7 @@
1
+ -- Verify schemas/totp/procedures/urlencode on pg
2
+
3
+ BEGIN;
4
+
5
+ SELECT verify_function ('totp.urlencode');
6
+
7
+ ROLLBACK;
@@ -0,0 +1,7 @@
1
+ -- Verify schemas/totp/schema on pg
2
+
3
+ BEGIN;
4
+
5
+ SELECT verify_schema ('totp');
6
+
7
+ ROLLBACK;