import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:halkyon_base_client/srp.dart'; import 'package:test/test.dart'; /// [parts], each after its length in four bytes, big-endian. List _framed(List> parts) => [ for (final part in parts) ...[ (part.length >> 24) & 0xff, (part.length >> 16) & 0xff, (part.length >> 8) & 0xff, part.length & 0xff, ...part, ], ]; void main() { final challenge = List.generate(16, (i) => i); final nonce = List.generate(32, (i) => 200 + i % 50); test("SRP's identity is the label, the challenge, the instance's base URL " 'the device addresses, what the proof is for and the digest of what it ' 'sets, each framed by its length', () { final digest = List.generate(32, (i) => i * 5); expect( passwordIdentity( challenge, 'https://learn.halkyon.eu/', purpose: PasswordProofPurpose.newPassword, digest: digest, ), _framed([ utf8.encode('halkyon srp identity v2'), challenge, utf8.encode('https://learn.halkyon.eu/'), utf8.encode('new password'), digest, ]), ); expect( passwordIdentity(challenge, 'https://learn.halkyon.eu/'), _framed([ utf8.encode('halkyon srp identity v2'), challenge, utf8.encode('https://learn.halkyon.eu/'), utf8.encode('sign in'), [], ]), ); }); test('every purpose has a label of its own', () { final labels = { for (final purpose in PasswordProofPurpose.values) purpose.label, }; expect(labels, hasLength(PasswordProofPurpose.values.length)); }); test('an identity differs for another instance, purpose or digest', () { final identity = passwordIdentity( challenge, 'https://learn.halkyon.eu/', purpose: PasswordProofPurpose.newPassword, digest: [1], ); for (final other in [ passwordIdentity( challenge, 'https://learn.halkyon.eu:8443/', purpose: PasswordProofPurpose.newPassword, digest: [1], ), passwordIdentity( challenge, 'https://learn.halkyon.eu/other/', purpose: PasswordProofPurpose.newPassword, digest: [1], ), passwordIdentity( challenge, 'https://learn.halkyon.eu/', purpose: PasswordProofPurpose.newPhrase, digest: [1], ), passwordIdentity( challenge, 'https://learn.halkyon.eu/', purpose: PasswordProofPurpose.newPassword, digest: [2], ), ]) { expect(other, isNot(identity)); } }); group("an instance's base URL", () { for (final (typed, canonical) in [ ('https://learn.halkyon.eu/', 'https://learn.halkyon.eu/'), ('https://Learn.Halkyon.EU', 'https://learn.halkyon.eu/'), ('HTTPS://learn.halkyon.eu:443/', 'https://learn.halkyon.eu/'), ('http://localhost:80/', 'http://localhost/'), ('http://localhost:8080', 'http://localhost:8080/'), ('http://localhost:0/', 'http://localhost:0/'), ('https://example.org:8443/learn', 'https://example.org:8443/learn/'), ('https://example.org/learn/?q=1#top', 'https://example.org/learn/'), ('http://[::1]:8080/', 'http://[::1]:8080/'), (' https://learn.halkyon.eu/ ', 'https://learn.halkyon.eu/'), ]) { test('$typed is $canonical', () { expect(canonicalInstanceUrl(typed), canonical); }); } for (final typed in [ 'learn.halkyon.eu', 'ftp://learn.halkyon.eu/', 'https:///', 'https://someone@learn.halkyon.eu/', ]) { test('$typed is none', () { expect(() => canonicalInstanceUrl(typed), throwsFormatException); }); } }); test('the key proof message is the label, the challenge, the nonce, the ' "instance's base URL, what it proves the phrase for and what that " 'sets, framed, as the register checks it', () { final digest = List.generate(32, (i) => i * 3); expect( keyProofMessage( challenge, nonce, instanceUrl: 'https://learn.halkyon.eu/', purpose: KeyProofPurpose.passwordReset, digest: digest, ), _framed([ utf8.encode('halkyon key proof v3'), challenge, nonce, utf8.encode('https://learn.halkyon.eu/'), utf8.encode('password reset'), digest, ]), ); expect( keyProofMessage( challenge, nonce, instanceUrl: 'https://learn.halkyon.eu/', purpose: KeyProofPurpose.authenticatorReset, ), _framed([ utf8.encode('halkyon key proof v3'), challenge, nonce, utf8.encode('https://learn.halkyon.eu/'), utf8.encode('authenticator reset'), [], ]), ); }); test("a new password's digest is SHA-256 over its parts, framed: the " 'salt, the costs, the verifier and the wrapping', () { final digest = newPasswordDigest( salt: [1, 2], algorithm: 'argon2id13', opsLimit: 3, memLimit: 67108864, verifier: [3, 4, 5], keysNonce: [6], keysCipherText: [7, 8], ); expect( digest, sha256 .convert( _framed([ utf8.encode('halkyon new password v1'), [1, 2], utf8.encode('argon2id13'), utf8.encode('3'), utf8.encode('67108864'), [3, 4, 5], [6], [7, 8], ]), ) .bytes, ); expect( newPasswordDigest( salt: [1, 2], algorithm: 'argon2id13', opsLimit: 3, memLimit: 67108864, verifier: [3, 4, 5], keysNonce: [6], keysCipherText: [7, 9], ), isNot(digest), ); }); test("a new phrase's digest is SHA-256 over the keys' wrapping under it " 'and its proof key, framed', () { final digest = newPhraseDigest( keysNonce: [1, 2], keysCipherText: [3, 4, 5], proofKey: [6, 7], ); expect( digest, sha256 .convert( _framed([ utf8.encode('halkyon new phrase v1'), [1, 2], [3, 4, 5], [6, 7], ]), ) .bytes, ); expect( newPhraseDigest( keysNonce: [1, 2], keysCipherText: [3, 4, 5], proofKey: [6, 8], ), isNot(digest), ); }); test("an authenticator's digest is SHA-256 over its ID, framed", () { final id = List.generate(16, (i) => 100 + i); expect( authenticatorDigest(id), sha256 .convert(_framed([utf8.encode('halkyon authenticator v1'), id])) .bytes, ); expect( authenticatorDigest([...id.take(15), 0]), isNot(authenticatorDigest(id)), ); }); }